Build a Custom Memory Allocation Toolkit

Build arena and pool allocators in C++20 and benchmark them against new/delete.

Introduction

30 Second Summary

Every time your program calls new, it asks the operating system for memory. That request travels through layers of bookkeeping, locking, and searching. For programs that create and destroy thousands of objects per second, this overhead adds up fast.

In this project, you will build two custom memory allocators in C++20 and prove they outperform the default heap by benchmarking them in a particle system simulation.

What You'll Build

A header-only memory allocation toolkit containing an arena allocator and a pool allocator, both compatible with STL containers via std::allocator_traits, exercised through a particle system benchmark.

By the end of this project, you'll have:

  • A working arena allocator that manages a contiguous memory block and frees everything at once when destroyed, demonstrating RAII in its purest form.
  • A working pool allocator that serves fixed-size blocks via a free list with O(1) allocation and deallocation.
  • A particle system benchmark proving your custom allocators outperform raw new/delete, measured with std::chrono::steady_clock.
  • Secret Mission: Add a memory diagnostics layer that tracks peak usage, allocation count, and fragmentation stats.

Are there any prerequisites?

This project assumes you are familiar with C++ pointers and smart pointers. You should also be comfortable with basic template syntax.

You do not need prior experience with custom allocators or the C++ allocator model. The project teaches both from scratch.

Set Up Your Toolchain and Project Structure

Custom memory allocators demand a compiler that supports modern C++ features like concepts and constexpr evaluation. Before writing a single line of allocator code, you need to confirm your toolchain handles C++20 without issues.

In this step, you will verify that your compiler supports C++20, create the project directory with dedicated header files for each allocator, and write a Makefile that automates compilation.

In this step, get ready to:
  • Confirm your compiler supports C++20 concepts.
  • Create the project directory with header and source files.
  • Write a Makefile that compiles with optimization and warning flags.
Verify your compiler supports C++20

On macOS, the g++ command is actually Apple Clang, not GNU GCC. Apple Clang fully supports C++20 concepts, which you will use throughout this project to constrain your allocator templates.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (the backtick key, next to the 1 key).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Check your compiler version by running:
g++ --version

You should see output mentioning Apple clang version followed by a version number.

What is g++ on macOS?

On macOS, g++ is an alias for Apple Clang, which ships with the Xcode Command Line Tools. It supports C++20 features including concepts, so you can use the -std=c++20 flag to enable them.

Now you will compile a small test file that uses C++20 concepts. If this compiles and runs, your toolchain is ready.

  • Create a test file by running this command:
cat > test.cpp << 'EOF'
#include <concepts>
#include <iostream>

// A function constrained by the std::integral concept
template <std::integral T>
T add(T a, T b) { return a + b; }

int main() {
    std::cout << add(3, 4) << std::endl;
    return 0;
}
EOF

What does this command do?

The cat > test.cpp << 'EOF' syntax is called a heredoc. It writes everything between the two EOF markers into a new file called test.cpp.

The test file uses std::integral, a C++20 concept that constrains a template to only accept integer types. If your compiler does not support C++20, this file will fail to compile.

  • Compile the test file by running:
g++ -std=c++20 -o test test.cpp
  • Run the compiled test by running:
./test

You should see 7 printed in your terminal.

✔️ I see 7 printed

Your compiler handles C++20 concepts. You are ready to build your allocators.

ⓧ I see a compilation error

Your Xcode Command Line Tools may be outdated. Update them by running:

xcode-select --install

After the installation finishes, try the g++ -std=c++20 -o test test.cpp command again.

Still seeing an error?

Make sure your macOS is updated to at least Catalina (10.15) or later. Older macOS versions ship with Clang versions that do not support C++20 concepts.

help me fix my C++20 compilation error on macOS

  • Clean up the test files by running:
rm test.cpp test
Create the project directory structure

Your allocator toolkit needs a clean directory with separate header files for each allocator and a main benchmark source file. Header files let you include allocator code in any future program without recompilation.

  • Create your project folder and move into it by running these commands:
mkdir memory-toolkit
cd memory-toolkit
  • Create the empty project files by running:
touch arena_allocator.h pool_allocator.h benchmark.cpp
  • Confirm the files exist by running:
ls

You should see arena_allocator.h, benchmark.cpp, and pool_allocator.h in the output.

Don't see all three files?

Make sure you are inside the memory-toolkit directory. Run pwd to check your current location. If you are still on ~/Desktop, run cd memory-toolkit first.

help me find my project files in the terminal

  • Open this folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to Desktop, select the memory-toolkit folder, and click Open.

You should see arena_allocator.h, benchmark.cpp, and pool_allocator.h listed in the Explorer sidebar on the left.

Create the Makefile

A Makefile automates your compilation command so you do not have to type the full g++ invocation every time you build. It also ensures consistent flags across every build.

  • In the Explorer sidebar, hover over the MEMORY-TOOLKIT heading and click the New File icon.
  • Type Makefile (no file extension) and press Enter.
  • Paste the following content into Makefile:
CXX = g++
CXXFLAGS = -std=c++20 -O2 -Wall -Wextra
TARGET = benchmark

all: $(TARGET)

$(TARGET): benchmark.cpp
	$(CXX) $(CXXFLAGS) -o $(TARGET) benchmark.cpp

clean:
	rm -f $(TARGET)

What does this Makefile do?

  • CXX = g++ sets the compiler to g++ (Apple Clang on macOS).
  • CXXFLAGS enables C++20, turns on level-2 optimization, and activates all common warnings.
  • The all target compiles benchmark.cpp into an executable called benchmark.
  • The clean target removes the compiled binary so you can rebuild from scratch.

Makefile indentation must use tabs, not spaces

The lines starting with $(CXX) and rm -f must be indented with a real tab character, not spaces. If you use spaces, make will fail with a "missing separator" error.

VS Code may convert tabs to spaces by default. Check the bottom status bar for "Spaces: 4" and click it to switch to "Indent Using Tabs" for this file.

  • Save Makefile by pressing Cmd+S (macOS) or Ctrl+S (Windows).

You will not see a visible change yet because benchmark.cpp is still empty. The Makefile will produce the executable once you add code in the next steps.

Seeing a "missing separator" error when you run make?

This means the recipe lines use spaces instead of tabs. Delete the whitespace before $(CXX) and before rm -f, then re-insert it using the Tab key.

help me fix my Makefile indentation

✔️ Awesome, I've got everything!

Great. Double check you have saved your Makefile and that your Explorer sidebar shows all four files: Makefile, arena_allocator.h, benchmark.cpp, and pool_allocator.h.

ⓧ I'd like to double check the full code

Here is the complete Makefile for comparison. The other three files should be empty at this point.

CXX = g++
CXXFLAGS = -std=c++20 -O2 -Wall -Wextra
TARGET = benchmark

all: $(TARGET)

$(TARGET): benchmark.cpp
	$(CXX) $(CXXFLAGS) -o $(TARGET) benchmark.cpp

clean:
	rm -f $(TARGET)

Your toolchain is verified and your project skeleton is in place. Next up, you will build the arena allocator that manages a contiguous memory block and frees everything at once when it goes out of scope.

Build the Arena Allocator

Every time you call new in C++, the program asks the operating system for a chunk of heap memory. For a few allocations this is fine, but when your code creates thousands of objects per frame, the overhead of repeated heap requests adds up fast.

An arena allocator solves this by grabbing one large block of memory upfront, then handing out slices from that block with nothing more than pointer arithmetic. When the arena is destroyed, the entire block is freed in one shot. This is RAII at its clearest: the resource lifetime is tied to the object lifetime.

In this step, get ready to:
  • Implement the ArenaResource class that manages a contiguous memory buffer with RAII cleanup.
  • Write the ArenaAllocator template that wraps the resource with an STL-compatible interface.
  • Test your arena allocator by using it with std::vector.
Implement the ArenaResource class

The ArenaResource class is the low-level engine of your arena allocator. It owns the raw memory buffer, tracks how much has been used via a bump pointer, and handles alignment with std::align.

  • In VS Code, open arena_allocator.h from the Explorer sidebar.
  • Replace the empty file with the following code:
#ifndef ARENA_ALLOCATOR_H
#define ARENA_ALLOCATOR_H

#include <cstddef>
#include <cstdint>
#include <memory>
#include <new>
#include <type_traits>

class ArenaResource {
public:
    explicit ArenaResource(std::size_t size)
        : buffer_(new std::byte[size]), capacity_(size), offset_(0) {}

    ~ArenaResource() {
        delete[] buffer_;
    }

    ArenaResource(const ArenaResource&) = delete;
    ArenaResource& operator=(const ArenaResource&) = delete;

    void* allocate(std::size_t bytes, std::size_t alignment) {
        void* current = buffer_ + offset_;
        std::size_t space = capacity_ - offset_;

        if (std::align(alignment, bytes, current, space)) {
            offset_ = capacity_ - space + bytes;
            return current;
        }
        throw std::bad_alloc();
    }

    void reset() noexcept {
        offset_ = 0;
    }

    std::size_t used() const noexcept { return offset_; }
    std::size_t capacity() const noexcept { return capacity_; }

private:
    std::byte* buffer_;
    std::size_t capacity_;
    std::size_t offset_;
};

#endif

What does this code do?

The constructor calls new std::byte[size] to grab one contiguous block from the heap. From this point on, no further heap allocations happen.

The allocate method uses std::align to find the next properly aligned position in the buffer, then bumps offset_ forward. If there is not enough space remaining, it throws std::bad_alloc.

The destructor calls delete[] buffer_, freeing everything in one shot. This is your RAII moment: the resource lifetime is tied to the ArenaResource object's lifetime.

The reset() method sets the offset back to zero, reclaiming all memory without destroying the arena itself.

  • Save arena_allocator.h.

You won't see a visible change yet. The next substep adds the STL-compatible template that makes this resource usable with standard containers.

Seeing a red squiggly in VS Code?

If VS Code highlights std::byte or std::align as errors, your editor's IntelliSense may be defaulting to an older C++ standard. This does not affect compilation. You can ignore the squiggle for now, since you verified -std=c++20 works in Step 1.

Help me fix C++20 IntelliSense in VS Code.

Write the ArenaAllocator template

STL containers like std::vector don't talk to your ArenaResource directly. They expect an allocator that satisfies the Allocator named requirement: a value_type typedef, an allocate(n) method, a deallocate(p, n) method, and equality operators.

  • In arena_allocator.h, find the #endif at the bottom of the file.
  • Replace that #endif line with the following code (which includes a new #endif at the very end):
template <typename T>
class ArenaAllocator {
public:
    using value_type = T;
    using is_always_equal = std::false_type;

    explicit ArenaAllocator(ArenaResource* resource) noexcept
        : resource_(resource) {}

    template <typename U>
    ArenaAllocator(const ArenaAllocator<U>& other) noexcept
        : resource_(other.resource()) {}

    [[nodiscard]] T* allocate(std::size_t n) {
        return static_cast<T*>(
            resource_->allocate(n * sizeof(T), alignof(T)));
    }

    void deallocate(T*, std::size_t) noexcept {
        // Arena does not deallocate individually
    }

    ArenaResource* resource() const noexcept { return resource_; }

    template <typename U>
    bool operator==(const ArenaAllocator<U>& other) const noexcept {
        return resource_ == other.resource();
    }

    template <typename U>
    bool operator!=(const ArenaAllocator<U>& other) const noexcept {
        return resource_ != other.resource();
    }

    template <typename U>
    struct rebind {
        using other = ArenaAllocator<U>;
    };

private:
    ArenaResource* resource_;
};

#endif

What does this code do?

  • value_type tells the container what type this allocator serves.
  • is_always_equal = std::false_type signals that two ArenaAllocator instances are NOT interchangeable (each points to a different resource). Without this, containers may assume any two allocators are equivalent, leading to heap corruption.
  • allocate(n) delegates to resource_->allocate(n * sizeof(T), alignof(T)), converting the element count into bytes and passing the correct alignment.
  • deallocate is a no-op. The arena frees everything at destruction, not piece by piece.
  • The rebind struct lets containers create allocators for internal node types (e.g., std::list needs to allocate list nodes, not just the stored type).
  • Save arena_allocator.h.
  • Verify the header compiles by running this command:
g++ -std=c++20 -fsyntax-only -x c++ arena_allocator.h

If there is no output, the header compiled successfully.

Seeing a compilation error?

  • Check that the #endif appears exactly once at the very end of the file. If you still have the old #endif from substep 1, you will get a duplicate guard error.
  • Make sure the ArenaAllocator template appears after the closing }; of the ArenaResource class, not inside it.

Help me fix my arena_allocator.h compilation error.

Test the arena allocator with std::vector

The real proof that your allocator works is using it with a standard container. You will create a quick test file that pushes integers into a std::vector backed by your arena.

  • Create a test file called test_arena.cpp by running this command:
touch test_arena.cpp
  • Open test_arena.cpp in VS Code and paste the following code:
#include <iostream>
#include <vector>
#include "arena_allocator.h"

int main() {
    // Create a 4KB arena
    ArenaResource arena(4096);

    // Wrap it in an STL-compatible allocator for int
    ArenaAllocator<int> alloc(&arena);

    // Use the allocator with std::vector
    std::vector<int, ArenaAllocator<int>> vec(alloc);
    for (int i = 0; i < 10; ++i) {
        vec.push_back(i * 10);
    }

    // Print the results
    std::cout << "Vector contents: ";
    for (int v : vec) {
        std::cout << v << " ";
    }
    std::cout << "\nArena used: " << arena.used() << " bytes\n";
    return 0;
}

What does this test do?

It creates an ArenaResource with 4096 bytes, wraps it in an ArenaAllocator<int>, and passes that allocator to std::vector. Every time the vector needs memory (including when it grows), it pulls from your arena instead of the heap.

  • Save test_arena.cpp.
  • Compile and run the test by running these commands:
g++ -std=c++20 -o test_arena test_arena.cpp
./test_arena

You should see output similar to this:

Vector contents: 0 10 20 30 40 50 60 70 80 90 
Arena used: 64 bytes

Why 64 bytes?

The vector starts small and doubles its capacity as it grows. Each reallocation bumps the arena pointer forward (the old memory is never reclaimed individually, since deallocate is a no-op). The final number reflects all the memory the vector requested across its growth cycles.

  • Clean up the test file by running this command:
rm -f test_arena test_arena.cpp

Seeing a linker error or crash?

  • If you see an error about rebind or allocator_traits, confirm that your ArenaAllocator template includes the rebind struct and the cross-type copy constructor.
  • If the program crashes with a segfault, the arena may be too small. Confirm you passed 4096 to the ArenaResource constructor.
  • If std::bad_alloc is thrown, the vector's growth exceeded your arena capacity. Double-check that the arena size is 4096 bytes and that you are only pushing 10 elements.

Help me debug my arena allocator test.

✔️ Awesome, I've got everything!

Great. Double check you have saved arena_allocator.h and deleted the test file.

ⓧ I'd like to double check the full code

Here is the complete arena_allocator.h file:

#ifndef ARENA_ALLOCATOR_H
#define ARENA_ALLOCATOR_H

#include <cstddef>
#include <cstdint>
#include <memory>
#include <new>
#include <type_traits>

class ArenaResource {
public:
    explicit ArenaResource(std::size_t size)
        : buffer_(new std::byte[size]), capacity_(size), offset_(0) {}

    ~ArenaResource() {
        delete[] buffer_;
    }

    ArenaResource(const ArenaResource&) = delete;
    ArenaResource& operator=(const ArenaResource&) = delete;

    void* allocate(std::size_t bytes, std::size_t alignment) {
        void* current = buffer_ + offset_;
        std::size_t space = capacity_ - offset_;

        if (std::align(alignment, bytes, current, space)) {
            offset_ = capacity_ - space + bytes;
            return current;
        }
        throw std::bad_alloc();
    }

    void reset() noexcept {
        offset_ = 0;
    }

    std::size_t used() const noexcept { return offset_; }
    std::size_t capacity() const noexcept { return capacity_; }

private:
    std::byte* buffer_;
    std::size_t capacity_;
    std::size_t offset_;
};

template <typename T>
class ArenaAllocator {
public:
    using value_type = T;
    using is_always_equal = std::false_type;

    explicit ArenaAllocator(ArenaResource* resource) noexcept
        : resource_(resource) {}

    template <typename U>
    ArenaAllocator(const ArenaAllocator<U>& other) noexcept
        : resource_(other.resource()) {}

    [[nodiscard]] T* allocate(std::size_t n) {
        return static_cast<T*>(
            resource_->allocate(n * sizeof(T), alignof(T)));
    }

    void deallocate(T*, std::size_t) noexcept {
        // Arena does not deallocate individually
    }

    ArenaResource* resource() const noexcept { return resource_; }

    template <typename U>
    bool operator==(const ArenaAllocator<U>& other) const noexcept {
        return resource_ == other.resource();
    }

    template <typename U>
    bool operator!=(const ArenaAllocator<U>& other) const noexcept {
        return resource_ != other.resource();
    }

    template <typename U>
    struct rebind {
        using other = ArenaAllocator<U>;
    };

private:
    ArenaResource* resource_;
};

#endif

Your arena allocator is complete and proven. Next up, you will build a pool allocator that takes the opposite approach: instead of one big block freed all at once, it recycles fixed-size blocks individually with O(1) performance.

Build the Pool Allocator

Your arena allocator handles the "allocate many, free all at once" pattern. Now you need a second strategy for workloads where objects are the same size but have independent lifetimes.

A pool allocator pre-slices a buffer into fixed-size blocks and threads them into a free list. Allocation pops one block off the list. Deallocation pushes it back. Both operations are O(1), with zero fragmentation.

In this step, get ready to:
  • Design a free-list node structure that embeds the "next" pointer inside unused memory blocks.
  • Implement O(1) allocate and deallocate by popping and pushing from the free list head.
  • Add STL compatibility so your pool allocator works with containers like std::list.
Design the free-list node structure

The core trick of a pool allocator is reusing the memory of each free block to store the free-list pointer. When a block is not in use, its first few bytes hold a pointer to the next free block. When the block is handed out, those same bytes become the user's data.

  • Open pool_allocator.h in VS Code (it should be empty) and add the following code:
#ifndef POOL_ALLOCATOR_H
#define POOL_ALLOCATOR_H

#include <cstddef>
#include <cstdint>
#include <new>
#include <type_traits>

class PoolResource {
public:
    explicit PoolResource(std::size_t block_size, std::size_t block_count)
        : buffer_(new std::byte[block_size * block_count]),
          block_size_(block_size),
          block_count_(block_count),
          free_list_(nullptr) {
        // Chain all blocks into the free list
        for (std::size_t i = 0; i < block_count; ++i) {
            auto* block = reinterpret_cast<FreeNode*>(buffer_ + i * block_size);
            block->next = free_list_;
            free_list_ = block;
        }
    }

    ~PoolResource() {
        delete[] buffer_;
    }

    PoolResource(const PoolResource&) = delete;
    PoolResource& operator=(const PoolResource&) = delete;

private:
    struct FreeNode {
        FreeNode* next;
    };

    std::byte* buffer_;
    std::size_t block_size_;
    std::size_t block_count_;
    FreeNode* free_list_;
};

#endif

What does this code do?

FreeNode is a tiny struct with a single next pointer. It lives inside each unused block, consuming no extra memory. When the block gets handed to the user, the pointer bytes become user data.

The constructor allocates one contiguous buffer (new std::byte[block_size * block_count]) and chains every block into the free list by writing a FreeNode at the start of each block. The destructor calls delete[] buffer_ to free all memory at once. This is your RAII cleanup.

Why is the free list built in reverse?

The loop pushes each block onto the front of the list, so the last block in the buffer ends up at the head. This is perfectly fine. Free-list order does not affect correctness or performance. Every pop and push is still O(1).

  • Save pool_allocator.h.

The file will not compile as a standalone unit yet. That is expected. You will add the remaining methods next.

Implement allocate and deallocate

With the free list wired up, allocation is a single pointer swap. Deallocation is the reverse operation. Both are O(1) regardless of how many blocks exist.

  • In pool_allocator.h, find this line:
    PoolResource& operator=(const PoolResource&) = delete;
  • Add the following methods directly below it (before the private: section):
    void* allocate() {
        if (!free_list_) {
            throw std::bad_alloc();
        }
        FreeNode* block = free_list_;
        free_list_ = free_list_->next;
        return static_cast<void*>(block);
    }

    void deallocate(void* ptr) noexcept {
        auto* block = static_cast<FreeNode*>(ptr);
        block->next = free_list_;
        free_list_ = block;
    }

    std::size_t block_size() const noexcept { return block_size_; }
    std::size_t block_count() const noexcept { return block_count_; }

What does this code do?

allocate() pops the head of the free list and returns it to the caller. If the list is empty, it throws std::bad_alloc so the caller knows the pool is exhausted.

deallocate() reinterprets the returned pointer as a FreeNode and pushes it back onto the free list head. The block is now available for the next allocation. No system calls, no searching.

The two accessors (block_size() and block_count()) expose pool metadata for diagnostics and the allocator template.

  • Save pool_allocator.h.

Seeing a compile error about FreeNode?

Make sure the allocate() and deallocate() methods are placed in the public: section, between the deleted copy operators and the private: keyword. The FreeNode struct must remain in the private: section below.

help me fix a FreeNode compile error in my pool allocator

Add allocator_traits compatibility and test with std::list

Just like the arena, you need a thin template wrapper so STL containers can use your pool. The key difference is that a pool serves exactly one block per allocation. If a container asks for more than one object at a time, the allocator rejects the request.

  • In pool_allocator.h, find the closing line #endif at the very bottom of the file.
  • Add the following template class directly above #endif:
template <typename T>
class PoolAllocator {
public:
    using value_type = T;
    using is_always_equal = std::false_type;

    explicit PoolAllocator(PoolResource* resource) noexcept
        : resource_(resource) {}

    template <typename U>
    PoolAllocator(const PoolAllocator<U>& other) noexcept
        : resource_(other.resource()) {}

    [[nodiscard]] T* allocate(std::size_t n) {
        if (n != 1) {
            throw std::bad_alloc();  // Pool only serves single blocks
        }
        return static_cast<T*>(resource_->allocate());
    }

    void deallocate(T* ptr, std::size_t) noexcept {
        resource_->deallocate(static_cast<void*>(ptr));
    }

    PoolResource* resource() const noexcept { return resource_; }

    template <typename U>
    bool operator==(const PoolAllocator<U>& other) const noexcept {
        return resource_ == other.resource();
    }

    template <typename U>
    bool operator!=(const PoolAllocator<U>& other) const noexcept {
        return resource_ != other.resource();
    }

    template <typename U>
    struct rebind {
        using other = PoolAllocator<U>;
    };

private:
    PoolResource* resource_;
};

What does this code do?

This mirrors the ArenaAllocator template you built earlier, with one important difference. The allocate(n) method throws std::bad_alloc if n != 1. A pool serves one fixed-size block at a time. Containers like std::list always allocate one node at a time, making them a perfect match.

The cross-type copy constructor and rebind struct let the STL internally convert between PoolAllocator<int> and PoolAllocator<__list_node<int>> behind the scenes.

Now verify that std::list can use your allocator by writing a quick test.

  • Create a file called test_pool.cpp in your project folder and add this code:
#include <iostream>
#include <list>
#include "pool_allocator.h"

int main() {
    // Use 64-byte blocks (plenty of space for a list node)
    PoolResource pool(64, 100);
    PoolAllocator<int> alloc(&pool);

    // std::list will rebind the allocator to its internal node type
    std::list<int, PoolAllocator<int>> mylist(alloc);

    for (int i = 0; i < 10; ++i) {
        mylist.push_back(i);
    }

    for (int val : mylist) {
        std::cout << val << " ";
    }
    std::cout << "\n";
    return 0;
}
  • Compile and run the test by running these commands:
g++ -std=c++20 -O2 -Wall -o test_pool test_pool.cpp
./test_pool

You should see 0 1 2 3 4 5 6 7 8 9 printed to the terminal.

Why use 64-byte blocks?

A std::list node contains prev/next pointers plus your data. On a 64-bit system, a list node for int is typically around 24 bytes. Using 64-byte blocks gives comfortable headroom without needing to know the exact internal node layout.

  • Clean up the test file by running:
rm test_pool test_pool.cpp

Seeing a bad_alloc or segfault?

If you get std::bad_alloc, your block size might be too small for the list node on your platform. Try increasing from 64 to 128. If you see a segfault, double-check that the rebind struct uses PoolAllocator<U> (not PoolAllocator<T>).

help me debug my pool allocator test with std::list

✔️ Awesome, I've got everything!

Great. Make sure you have saved pool_allocator.h with all three sections: PoolResource class and PoolAllocator<T> template.

ⓧ I'd like to double check the full code

#ifndef POOL_ALLOCATOR_H
#define POOL_ALLOCATOR_H

#include <cstddef>
#include <cstdint>
#include <new>
#include <type_traits>

class PoolResource {
public:
    explicit PoolResource(std::size_t block_size, std::size_t block_count)
        : buffer_(new std::byte[block_size * block_count]),
          block_size_(block_size),
          block_count_(block_count),
          free_list_(nullptr) {
        // Chain all blocks into the free list
        for (std::size_t i = 0; i < block_count; ++i) {
            auto* block = reinterpret_cast<FreeNode*>(buffer_ + i * block_size);
            block->next = free_list_;
            free_list_ = block;
        }
    }

    ~PoolResource() {
        delete[] buffer_;
    }

    PoolResource(const PoolResource&) = delete;
    PoolResource& operator=(const PoolResource&) = delete;

    void* allocate() {
        if (!free_list_) {
            throw std::bad_alloc();
        }
        FreeNode* block = free_list_;
        free_list_ = free_list_->next;
        return static_cast<void*>(block);
    }

    void deallocate(void* ptr) noexcept {
        auto* block = static_cast<FreeNode*>(ptr);
        block->next = free_list_;
        free_list_ = block;
    }

    std::size_t block_size() const noexcept { return block_size_; }
    std::size_t block_count() const noexcept { return block_count_; }

private:
    struct FreeNode {
        FreeNode* next;
    };

    std::byte* buffer_;
    std::size_t block_size_;
    std::size_t block_count_;
    FreeNode* free_list_;
};

template <typename T>
class PoolAllocator {
public:
    using value_type = T;
    using is_always_equal = std::false_type;

    explicit PoolAllocator(PoolResource* resource) noexcept
        : resource_(resource) {}

    template <typename U>
    PoolAllocator(const PoolAllocator<U>& other) noexcept
        : resource_(other.resource()) {}

    [[nodiscard]] T* allocate(std::size_t n) {
        if (n != 1) {
            throw std::bad_alloc();  // Pool only serves single blocks
        }
        return static_cast<T*>(resource_->allocate());
    }

    void deallocate(T* ptr, std::size_t) noexcept {
        resource_->deallocate(static_cast<void*>(ptr));
    }

    PoolResource* resource() const noexcept { return resource_; }

    template <typename U>
    bool operator==(const PoolAllocator<U>& other) const noexcept {
        return resource_ == other.resource();
    }

    template <typename U>
    bool operator!=(const PoolAllocator<U>& other) const noexcept {
        return resource_ != other.resource();
    }

    template <typename U>
    struct rebind {
        using other = PoolAllocator<U>;
    };

private:
    PoolResource* resource_;
};

#endif

Your pool allocator is complete. Next up, you will build a particle system that puts both allocators to work under real pressure.

Build the Particle System

Your allocators are built and tested with STL containers. Now it's time to put them to real work.

A particle system is the perfect stress test for custom allocators. Every frame, hundreds of particles spawn, move, and die. That constant churn of allocations and deallocations is exactly where the default heap struggles and your allocators shine.

In this step, get ready to:
  • Define a Particle struct and the benchmark scaffolding.
  • Build a pool-allocated particle simulation using placement new.
  • Build an arena-allocated version with per-frame reset.
Define the Particle struct and benchmark setup

A particle needs position, velocity, a lifetime counter, and a flag indicating whether it's still alive. This struct is the fixed-size element your pool allocator will serve.

  • Open benchmark.cpp in VS Code (it should be empty) and add the following includes, struct definition, and global accumulator:
#include <chrono>
#include <cstddef>
#include <cstdlib>
#include <iomanip>
#include <iostream>
#include <vector>

#include "arena_allocator.h"
#include "pool_allocator.h"

// A single particle with position, velocity, lifetime, and active flag
struct Particle {
    float x, y;
    float vx, vy;
    float lifetime;
    bool active;
};

// Volatile prevents the compiler from optimizing away our benchmark loops
volatile std::size_t sink = 0;

What does this code do?

The Particle struct holds 5 floats (20 bytes) plus a bool, totalling about 24 bytes with padding. This is comfortably larger than a pointer (8 bytes), which means the pool allocator can safely embed its free-list node inside unused particle memory.

The volatile std::size_t sink variable is a benchmarking trick. Without a visible side effect, the -O2 compiler flag might eliminate your allocation loops entirely since they produce no observable output.

  • Save benchmark.cpp.

You won't see a change yet. The next two substeps add the benchmark functions that exercise this struct.

Implement the pool allocator particle simulation

Every particle is the same fixed size. That makes the pool allocator a perfect fit. Each frame, you pop blocks from the free list to spawn particles, then push them back when particles die.

What is placement new?

Placement new constructs an object at a specific memory address you provide, instead of asking the heap for fresh memory. The syntax new (mem) Particle{} says "build a Particle right here, at this address I already own."

Because you allocated the memory yourself (from the pool), you also need to manually call the destructor (p->~Particle()) before returning the block to the pool.

  • In benchmark.cpp, add the benchmark_pool function below the sink variable:
long long benchmark_pool(std::size_t frames, std::size_t particles_per_frame) {
    PoolResource pool(sizeof(Particle), particles_per_frame);
    auto start = std::chrono::steady_clock::now();

    for (std::size_t frame = 0; frame < frames; ++frame) {
        // Track particles spawned this frame
        std::vector<Particle*> particles;
        particles.reserve(particles_per_frame);

        // Spawn: pop a block from the pool, construct a particle in it
        for (std::size_t i = 0; i < particles_per_frame; ++i) {
            auto* mem = pool.allocate();
            auto* p = new (mem) Particle{};
            p->x = static_cast<float>(i);
            p->y = static_cast<float>(frame);
            p->vx = 1.0f;
            p->vy = -1.0f;
            p->lifetime = 2.0f;
            p->active = true;
            particles.push_back(p);
        }

        // Update: move particles and accumulate into sink
        for (auto* p : particles) {
            p->x += p->vx * 0.016f;
            p->y += p->vy * 0.016f;
            p->lifetime -= 0.016f;
            sink += static_cast<std::size_t>(p->x);
        }

        // Destroy: manually call destructor, return block to pool
        for (auto* p : particles) {
            p->~Particle();
            pool.deallocate(static_cast<void*>(p));
        }
    }

    auto end = std::chrono::steady_clock::now();
    return std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
}

What does this function do?

Each frame follows a spawn-update-destroy cycle. The PoolResource is created once with enough blocks for one frame's worth of particles. Every frame, blocks are popped (O(1)) for spawning and pushed back (O(1)) for destruction.

The sink += static_cast<std::size_t>(p->x) line forces the compiler to actually compute the particle updates. Without it, the optimizer would see the loop does nothing observable and remove it entirely.

  • Save benchmark.cpp.

You won't see a change yet. The arena version comes next, and then you'll compile everything together in the following step.

Seeing red squiggles in VS Code?

If your editor shows errors on std::chrono or the allocator includes, make sure all your header files (arena_allocator.h, pool_allocator.h) are in the same directory as benchmark.cpp.

help me fix IntelliSense errors in my C++20 custom allocator project

Implement the arena allocator particle simulation

The arena allocator uses a completely different strategy. Instead of recycling individual blocks, you allocate all particles for a frame by bumping a pointer forward, then reset the entire arena at the start of the next frame.

This is the "allocate many, free all at once" pattern. No individual deallocation calls are needed.

  • In benchmark.cpp, add the benchmark_arena function below benchmark_pool:
long long benchmark_arena(std::size_t frames, std::size_t particles_per_frame) {
    // Size the arena to hold all particles plus padding for alignment
    ArenaResource arena(particles_per_frame * sizeof(Particle) + 1024);
    auto start = std::chrono::steady_clock::now();

    for (std::size_t frame = 0; frame < frames; ++frame) {
        // Reset reclaims all memory without destroying the arena
        arena.reset();
        std::vector<Particle*> particles;
        particles.reserve(particles_per_frame);

        // Spawn: bump the pointer forward for each particle
        for (std::size_t i = 0; i < particles_per_frame; ++i) {
            auto* mem = arena.allocate(sizeof(Particle), alignof(Particle));
            auto* p = new (mem) Particle{};
            p->x = static_cast<float>(i);
            p->y = static_cast<float>(frame);
            p->vx = 1.0f;
            p->vy = -1.0f;
            p->lifetime = 2.0f;
            p->active = true;
            particles.push_back(p);
        }

        // Update: move particles and accumulate into sink
        for (auto* p : particles) {
            p->x += p->vx * 0.016f;
            p->y += p->vy * 0.016f;
            p->lifetime -= 0.016f;
            sink += static_cast<std::size_t>(p->x);
        }
        // No individual deallocation needed - arena resets next frame
    }

    auto end = std::chrono::steady_clock::now();
    return std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
}

What does this function do?

The key difference from the pool version is the arena.reset() call at the start of each frame. This single call reclaims ALL memory by resetting the bump pointer to zero. No per-particle destructor calls, no per-particle deallocation.

The extra 1024 bytes in the arena size (particles_per_frame * sizeof(Particle) + 1024) provides headroom for alignment padding that std::align may insert between allocations.

Why two different allocator strategies?

The pool allocator excels when objects are created and destroyed individually throughout their lifetime. Each particle gets its own block that can be recycled independently.

The arena allocator excels when all objects share a batch lifetime. If everything allocated in a frame dies at the end of that frame, resetting the pointer is far cheaper than hundreds of individual deallocations.

  • Save benchmark.cpp.
  • Verify there are no syntax errors by compiling without linking:
g++ -std=c++20 -O2 -Wall -Wextra -c benchmark.cpp

If the command completes with no output, your code compiles cleanly. You should see a benchmark.o file appear in your project directory.

Seeing compiler errors?

Check that your Particle struct is defined before both benchmark functions. The compiler reads top to bottom, so the struct must appear first.

If you see errors about PoolResource or ArenaResource not being declared, confirm that pool_allocator.h and arena_allocator.h are in the same folder as benchmark.cpp.

help me debug compiler errors in my C++20 particle system benchmark

✔️ Awesome, I've got everything!

Great. Double check you've saved benchmark.cpp before moving on.

ⓧ I'd like to double check the full code

Here is the complete benchmark.cpp so far. Compare your file against this:

#include <chrono>
#include <cstddef>
#include <cstdlib>
#include <iomanip>
#include <iostream>
#include <vector>

#include "arena_allocator.h"
#include "pool_allocator.h"

struct Particle {
    float x, y;
    float vx, vy;
    float lifetime;
    bool active;
};

volatile std::size_t sink = 0;

long long benchmark_pool(std::size_t frames, std::size_t particles_per_frame) {
    PoolResource pool(sizeof(Particle), particles_per_frame);
    auto start = std::chrono::steady_clock::now();

    for (std::size_t frame = 0; frame < frames; ++frame) {
        std::vector<Particle*> particles;
        particles.reserve(particles_per_frame);

        for (std::size_t i = 0; i < particles_per_frame; ++i) {
            auto* mem = pool.allocate();
            auto* p = new (mem) Particle{};
            p->x = static_cast<float>(i);
            p->y = static_cast<float>(frame);
            p->vx = 1.0f;
            p->vy = -1.0f;
            p->lifetime = 2.0f;
            p->active = true;
            particles.push_back(p);
        }

        for (auto* p : particles) {
            p->x += p->vx * 0.016f;
            p->y += p->vy * 0.016f;
            p->lifetime -= 0.016f;
            sink += static_cast<std::size_t>(p->x);
        }

        for (auto* p : particles) {
            p->~Particle();
            pool.deallocate(static_cast<void*>(p));
        }
    }

    auto end = std::chrono::steady_clock::now();
    return std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
}

long long benchmark_arena(std::size_t frames, std::size_t particles_per_frame) {
    ArenaResource arena(particles_per_frame * sizeof(Particle) + 1024);
    auto start = std::chrono::steady_clock::now();

    for (std::size_t frame = 0; frame < frames; ++frame) {
        arena.reset();
        std::vector<Particle*> particles;
        particles.reserve(particles_per_frame);

        for (std::size_t i = 0; i < particles_per_frame; ++i) {
            auto* mem = arena.allocate(sizeof(Particle), alignof(Particle));
            auto* p = new (mem) Particle{};
            p->x = static_cast<float>(i);
            p->y = static_cast<float>(frame);
            p->vx = 1.0f;
            p->vy = -1.0f;
            p->lifetime = 2.0f;
            p->active = true;
            particles.push_back(p);
        }

        for (auto* p : particles) {
            p->x += p->vx * 0.016f;
            p->y += p->vy * 0.016f;
            p->lifetime -= 0.016f;
            sink += static_cast<std::size_t>(p->x);
        }
    }

    auto end = std::chrono::steady_clock::now();
    return std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
}

Your particle system is ready. Both allocator strategies are implemented side by side. Next up, you'll add the timing harness and a comparison against raw new/delete to see exactly how much faster your allocators are.

Benchmark with std::chrono

You have two custom allocators and a particle system that exercises them. Now it is time to prove they actually outperform the default heap.

In this step you will write a baseline benchmark using raw new/delete, run all three strategies multiple times, and print a comparison table showing the speedup ratios. You will use std::chrono::steady_clock for precise, monotonic timing.

In this step, get ready to:
  • Write the new/delete baseline benchmark function.
  • Build main() to run all strategies multiple times and compute averages.
  • Compile and run the benchmark to see the performance comparison.
Write the new/delete baseline benchmark

A benchmark is only useful if you have a baseline to compare against. The benchmark_new_delete() function represents what most programs do by default: call new for each particle and delete when done.

  • In benchmark.cpp, find the benchmark_pool() function.
  • Add the following benchmark_new_delete() function above it:
// Strategy 1: new/delete
long long benchmark_new_delete(std::size_t frames, std::size_t particles_per_frame) {
    auto start = std::chrono::steady_clock::now();

    for (std::size_t frame = 0; frame < frames; ++frame) {
        std::vector<Particle*> particles;
        particles.reserve(particles_per_frame);

        for (std::size_t i = 0; i < particles_per_frame; ++i) {
            auto* p = new Particle{};
            p->x = static_cast<float>(i);
            p->y = static_cast<float>(frame);
            p->vx = 1.0f;
            p->vy = -1.0f;
            p->lifetime = 2.0f;
            p->active = true;
            particles.push_back(p);
        }

        for (auto* p : particles) {
            p->x += p->vx * 0.016f;
            p->y += p->vy * 0.016f;
            p->lifetime -= 0.016f;
            sink += static_cast<std::size_t>(p->x);
        }

        for (auto* p : particles) {
            delete p;
        }
    }

    auto end = std::chrono::steady_clock::now();
    return std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
}

What does this code do?

This function follows the same particle simulation loop as your pool and arena benchmarks, but uses the default heap allocator.

  • new Particle{} asks the OS for a fresh heap block every single time. This is the most expensive path because each call may trigger a system call.
  • delete p returns each block individually, potentially fragmenting the heap over time.
  • sink += static_cast<std::size_t>(p->x) writes to the volatile variable you defined earlier. This prevents the compiler from optimizing away the entire loop under -O2.

Why volatile prevents dead-code elimination

When you compile with -O2, the compiler aggressively removes code whose results are never observed. If you allocate particles but never use their values in a visible side effect, the compiler can legally delete the entire benchmark loop.

The volatile keyword tells the compiler that sink might be read by something outside the program. The compiler must preserve every write to it, which forces the particle computations to stay in the binary.

You will not see a change yet. The next chunk adds main() which calls this function.

Add the main function with multi-run averaging

A single timing measurement can be noisy due to OS scheduling and cache warm-up. Running each strategy multiple times and averaging the results gives a much more stable comparison.

  • At the very bottom of benchmark.cpp, after the closing brace of benchmark_arena(), add the following main() function:
int main() {
    constexpr std::size_t frames = 1000;
    constexpr std::size_t particles_per_frame = 500;
    constexpr int runs = 5;

    std::cout << "Particle System Allocator Benchmark\n";
    std::cout << "Frames: " << frames
              << ", Particles/frame: " << particles_per_frame
              << ", Runs: " << runs << "\n\n";

    long long total_new = 0, total_pool = 0, total_arena = 0;

    for (int r = 0; r < runs; ++r) {
        total_new += benchmark_new_delete(frames, particles_per_frame);
        total_pool += benchmark_pool(frames, particles_per_frame);
        total_arena += benchmark_arena(frames, particles_per_frame);
    }

    long long avg_new = total_new / runs;
    long long avg_pool = total_pool / runs;
    long long avg_arena = total_arena / runs;

    std::cout << std::fixed << std::setprecision(2);
    std::cout << "+-----------------+--------------+----------+\n";
    std::cout << "| Strategy        | Avg Time(us) | Speedup  |\n";
    std::cout << "+-----------------+--------------+----------+\n";
    std::cout << "| new/delete      | " << std::setw(12) << avg_new
              << " | 1.00x    |\n";
    std::cout << "| Pool Allocator  | " << std::setw(12) << avg_pool
              << " | " << std::setw(5)
              << static_cast<double>(avg_new) / static_cast<double>(avg_pool)
              << "x  |\n";
    std::cout << "| Arena Allocator | " << std::setw(12) << avg_arena
              << " | " << std::setw(5)
              << static_cast<double>(avg_new) / static_cast<double>(avg_arena)
              << "x  |\n";
    std::cout << "+-----------------+--------------+----------+\n";

    return 0;
}

What does this code do?

  • constexpr std::size_t frames = 1000 and particles_per_frame = 500 set up a realistic workload: 500,000 total particle allocations per run.
  • The for (int r = 0; r < runs; ++r) loop runs each strategy 5 times. Accumulating totals and dividing by runs smooths out single-run variance.
  • The speedup ratio divides the new/delete time by each allocator's time. A ratio of 3.00x means the custom allocator is three times faster.
  • Save benchmark.cpp.

Seeing a compile error mentioning main?

  • Make sure main() is placed after all three benchmark functions, not inside one of them. Check for a missing closing brace on benchmark_arena().
  • Verify that benchmark_new_delete() appears before benchmark_pool() so that main() can see all three function declarations.

Why won't my benchmark.cpp compile?

✔️ Awesome, I've got everything!

Great. Double check you have saved benchmark.cpp before compiling in the next substep.

ⓧ I'd like to double check the full code

Here is the complete benchmark.cpp at this point:

#include <chrono>
#include <cstddef>
#include <cstdlib>
#include <iomanip>
#include <iostream>
#include <vector>

#include "arena_allocator.h"
#include "pool_allocator.h"

struct Particle {
    float x, y;
    float vx, vy;
    float lifetime;
    bool active;
};

volatile std::size_t sink = 0;

// Strategy 1: new/delete
long long benchmark_new_delete(std::size_t frames, std::size_t particles_per_frame) {
    auto start = std::chrono::steady_clock::now();

    for (std::size_t frame = 0; frame < frames; ++frame) {
        std::vector<Particle*> particles;
        particles.reserve(particles_per_frame);

        for (std::size_t i = 0; i < particles_per_frame; ++i) {
            auto* p = new Particle{};
            p->x = static_cast<float>(i);
            p->y = static_cast<float>(frame);
            p->vx = 1.0f;
            p->vy = -1.0f;
            p->lifetime = 2.0f;
            p->active = true;
            particles.push_back(p);
        }

        for (auto* p : particles) {
            p->x += p->vx * 0.016f;
            p->y += p->vy * 0.016f;
            p->lifetime -= 0.016f;
            sink += static_cast<std::size_t>(p->x);
        }

        for (auto* p : particles) {
            delete p;
        }
    }

    auto end = std::chrono::steady_clock::now();
    return std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
}

// Strategy 2: Pool allocator
long long benchmark_pool(std::size_t frames, std::size_t particles_per_frame) {
    PoolResource pool(sizeof(Particle), particles_per_frame);
    auto start = std::chrono::steady_clock::now();

    for (std::size_t frame = 0; frame < frames; ++frame) {
        std::vector<Particle*> particles;
        particles.reserve(particles_per_frame);

        for (std::size_t i = 0; i < particles_per_frame; ++i) {
            auto* mem = pool.allocate();
            auto* p = new (mem) Particle{};
            p->x = static_cast<float>(i);
            p->y = static_cast<float>(frame);
            p->vx = 1.0f;
            p->vy = -1.0f;
            p->lifetime = 2.0f;
            p->active = true;
            particles.push_back(p);
        }

        for (auto* p : particles) {
            p->x += p->vx * 0.016f;
            p->y += p->vy * 0.016f;
            p->lifetime -= 0.016f;
            sink += static_cast<std::size_t>(p->x);
        }

        for (auto* p : particles) {
            p->~Particle();
            pool.deallocate(static_cast<void*>(p));
        }
    }

    auto end = std::chrono::steady_clock::now();
    return std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
}

// Strategy 3: Arena allocator (per-frame reset)
long long benchmark_arena(std::size_t frames, std::size_t particles_per_frame) {
    ArenaResource arena(particles_per_frame * sizeof(Particle) + 1024);
    auto start = std::chrono::steady_clock::now();

    for (std::size_t frame = 0; frame < frames; ++frame) {
        arena.reset();
        std::vector<Particle*> particles;
        particles.reserve(particles_per_frame);

        for (std::size_t i = 0; i < particles_per_frame; ++i) {
            auto* mem = arena.allocate(sizeof(Particle), alignof(Particle));
            auto* p = new (mem) Particle{};
            p->x = static_cast<float>(i);
            p->y = static_cast<float>(frame);
            p->vx = 1.0f;
            p->vy = -1.0f;
            p->lifetime = 2.0f;
            p->active = true;
            particles.push_back(p);
        }

        for (auto* p : particles) {
            p->x += p->vx * 0.016f;
            p->y += p->vy * 0.016f;
            p->lifetime -= 0.016f;
            sink += static_cast<std::size_t>(p->x);
        }
        // No individual deallocation needed - arena resets next frame
    }

    auto end = std::chrono::steady_clock::now();
    return std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
}

int main() {
    constexpr std::size_t frames = 1000;
    constexpr std::size_t particles_per_frame = 500;
    constexpr int runs = 5;

    std::cout << "Particle System Allocator Benchmark\n";
    std::cout << "Frames: " << frames
              << ", Particles/frame: " << particles_per_frame
              << ", Runs: " << runs << "\n\n";

    long long total_new = 0, total_pool = 0, total_arena = 0;

    for (int r = 0; r < runs; ++r) {
        total_new += benchmark_new_delete(frames, particles_per_frame);
        total_pool += benchmark_pool(frames, particles_per_frame);
        total_arena += benchmark_arena(frames, particles_per_frame);
    }

    long long avg_new = total_new / runs;
    long long avg_pool = total_pool / runs;
    long long avg_arena = total_arena / runs;

    std::cout << std::fixed << std::setprecision(2);
    std::cout << "+-----------------+--------------+----------+\n";
    std::cout << "| Strategy        | Avg Time(us) | Speedup  |\n";
    std::cout << "+-----------------+--------------+----------+\n";
    std::cout << "| new/delete      | " << std::setw(12) << avg_new
              << " | 1.00x    |\n";
    std::cout << "| Pool Allocator  | " << std::setw(12) << avg_pool
              << " | " << std::setw(5)
              << static_cast<double>(avg_new) / static_cast<double>(avg_pool)
              << "x  |\n";
    std::cout << "| Arena Allocator | " << std::setw(12) << avg_arena
              << " | " << std::setw(5)
              << static_cast<double>(avg_new) / static_cast<double>(avg_arena)
              << "x  |\n";
    std::cout << "+-----------------+--------------+----------+\n";

    return 0;
}
Compile and run the benchmark

The Makefile you created earlier compiles with -O2, which is critical for realistic benchmark results. Without optimization, function call overhead dominates the measurements and hides the real allocation cost difference.

  • Compile the benchmark by running:
make
  • You should see no output if compilation succeeds. If you see errors, check the troubleshooting callout above.
  • Run the benchmark executable:
./benchmark
  • You should see a formatted table showing the average time in microseconds for each strategy and the speedup ratios. The pool and arena allocators should both show a speedup greater than 1.00x over new/delete.

What should I see?

Your exact numbers will vary depending on your machine, but you should see something like this structure:

Particle System Allocator Benchmark
Frames: 1000, Particles/frame: 500, Runs: 5

+-----------------+--------------+----------+
| Strategy        | Avg Time(us) | Speedup  |
+-----------------+--------------+----------+
| new/delete      |       XXXXX  | 1.00x    |
| Pool Allocator  |       XXXXX  | X.XXx    |
| Arena Allocator |       XXXXX  | X.XXx    |
+-----------------+--------------+----------+

The pool allocator avoids per-allocation system calls by popping from its pre-built free list. The arena allocator is typically the fastest because it just bumps a pointer forward with no bookkeeping at all.

Benchmark runs but shows 0 microseconds?

  • Make sure you compiled with make (which uses -O2) and not a manual g++ command without optimization flags.
  • Check that the volatile std::size_t sink = 0; line is present near the top of your file. Without it, the compiler may optimize away the entire benchmark loop.
  • Verify that the update loop writes to sink in all three benchmark functions.

My benchmark shows 0 or unrealistically low times. Help me debug.

Your allocator toolkit is complete and proven. You have hard numbers showing that custom memory management is not just theoretical. It delivers real, measurable performance gains for workloads with predictable allocation patterns.

Secret mission

Add a Memory Diagnostics Layer

Your allocators are fast, but how much memory are they actually using? In this bonus challenge, you'll build a diagnostic wrapper that tracks peak usage, allocation counts, and reports statistics at destruction. This is how production allocators surface memory pressure to monitoring systems.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files for future reference or delete them entirely.

Resources you used:

  • Local project files: Makefile, arena_allocator.h, pool_allocator.h, benchmark.cpp, diagnostic_allocator.h
  • Compiled executable: benchmark

Keep everything running

No action needed. Choose this if you plan to keep experimenting with your allocators, adding new strategies, or extending the benchmark.

  • Your header files and source code remain on your Desktop, ready for future use.
  • You can recompile at any time by running make from your project folder.

Pause - I'll come back to this later

Since all files are local, there is nothing to pause. Your files will stay on disk until you delete them.

  • Remove the compiled executable to free a small amount of disk space by running this from inside your project folder:
make clean

This removes the benchmark binary. Your source files remain intact for when you return.

Delete - I don't want to use this again

Remove all project files and the project folder entirely.

  • In your terminal, navigate to your Desktop:
cd ~/Desktop
  • Delete the project folder (replace the folder name with the name you chose when creating it):
rm -rf [[PROJECT_FOLDER="your project folder name"]]
  • On Windows, use this command instead:
rmdir /s /q [[PROJECT_FOLDER="your project folder name"]]
  • Confirm the folder is gone by running:
ls ~/Desktop

You should no longer see your project folder in the output.

Nice Work!

Nice Work!

You just built a custom memory allocation toolkit from scratch in C++20. You now own the mental model behind how game engines, compilers, and network servers manage memory for performance-critical workloads.

You've learned how to:

  • Build an arena allocator and a pool allocator that manage contiguous memory blocks with RAII cleanup and O(1) operations.
  • Make custom allocators compatible with STL containers via std::allocator_traits, including value_type, rebind, and equality operators.
  • Benchmark allocation strategies against new/delete using std::chrono::steady_clock in a particle system simulation, proving measurable speedups.
  • Secret Mission: Added a memory diagnostics layer that reports peak usage, allocation counts, and fragmentation stats.

Ready to quiz yourself?