FastAlloc: Fragmentation Benchmarking
Add fragmentation instrumentation and lifetime-aware benchmarks to FastAlloc.
Introduction
30 Second Summary
When software manages memory, it does not just need to be fast. It needs to be efficient. An allocator that grabs memory quickly but wastes half of it to fragmentation is a liability in production systems.
In this project, you will upgrade your existing FastAlloc memory allocator with professional fragmentation instrumentation and a lifetime-aware benchmark suite. You will measure what production systems actually care about: memory efficiency under realistic workloads.
What You'll Build
You will add an AllocatorStats instrumentation module and a suite of Google Benchmark workloads that report fragmentation metrics alongside throughput, with JSON output ready for portfolio documentation.
By the end of this project, you'll have:
- An AllocatorStats module with atomic counters that tracks live bytes, reserved bytes, and overhead ratio inside FastAlloc's hot path.
- A lifetime-aware benchmark suite with phased workloads (RampUp, Steady, BulkReclaim) that expose fragmentation behavior invisible to pure throughput tests.
- JSON benchmark output with custom counters showing overhead_ratio, live_bytes, reserved_bytes, and frag_pct alongside standard timing output, plus side-by-side comparison against std::malloc.
- Secret Mission: a cross-thread fragmentation stress test that measures how objects allocated on one thread and freed by another impact slab utilization through the MPSC pending queue.
Are there any prerequisites?
This project builds on an existing FastAlloc repository that you have already cloned and can build. You need a C++17 compiler (MSVC, GCC, or Clang), CMake, and Git already installed.
Google Benchmark v1.9.5 is fetched automatically by CMake during the build process, so you do not need to install it separately.
Set Up the Upgraded Benchmark Infrastructure
Your FastAlloc project handles allocation and deallocation. But right now there is no way to measure how efficiently it uses memory.
Throughput benchmarks tell you how fast allocations happen, not how much memory goes to waste. In this step, you will set up the infrastructure for fragmentation analysis by upgrading Google Benchmark to v1.9.5, creating an AllocatorStats module with atomic counters, and wiring everything into the build system.
In this step, get ready to:
- Update CMakeLists.txt to upgrade Google Benchmark and add new build targets.
- Create the AllocatorStats instrumentation header and source files.
- Build the project to confirm everything compiles cleanly.
Update CMakeLists.txt for Google Benchmark v1.9.5 and new targets
The existing CMakeLists.txt already fetches Google Benchmark v1.8.3 via FetchContent. You need to upgrade the pinned version, register the new stats source file in the library, and declare a new benchmark target.
- In your IDE, open CMakeLists.txt at the root of your FastAlloc project.
- Inside the if(FASTALLOC_BUILD_BENCHMARKS) block, find the FetchContent_Declare for googlebenchmark. Locate this line:
GIT_TAG v1.8.3
- Replace v1.8.3 with v1.9.5, so the line now looks like this:
GIT_TAG v1.9.5
Why upgrade to v1.9.5?
Version v1.9.5 is the latest stable release of Google Benchmark. You will use its state.counters API to report custom fragmentation metrics like overhead_ratio and frag_pct alongside standard timing output.
- Scroll up to the add_library(fast_alloc STATIC ...) block. Find the last source file in the list:
src/os_memory.cpp
)
- Add src/alloc_stats.cpp right before the closing parenthesis, so the end of the block now looks like this:
src/os_memory.cpp
src/alloc_stats.cpp
)
Now you need to add the benchmark target for the fragmentation workloads you will write in a later step.
- Scroll back down to the if(FASTALLOC_BUILD_BENCHMARKS) block. Find the three target_link_libraries lines at the bottom that link Threads::Threads to the existing benchmark targets. Add the following block directly above those three thread-linking lines:
# Fragmentation benchmark target
add_executable(fast_alloc_bench_fragmentation benchmarks/bench_fragmentation.cpp)
target_compile_options(fast_alloc_bench_fragmentation PRIVATE ${WARNING_FLAGS})
target_link_libraries(fast_alloc_bench_fragmentation PRIVATE fast_alloc benchmark::benchmark)
What does this target do?
This declares a new executable called fast_alloc_bench_fragmentation built from benchmarks/bench_fragmentation.cpp. It links against both your fast_alloc library and benchmark::benchmark so it can call FastAlloc functions and use the Google Benchmark harness.
- You also need to link Threads::Threads to the new target. Find the existing target_link_libraries lines for threading and add a new line for the fragmentation target right after them:
target_link_libraries(fast_alloc_bench_fragmentation PRIVATE Threads::Threads)
- Scroll down to the LTO block at the bottom of the file. Find the if(FASTALLOC_BUILD_BENCHMARKS) section inside if(lto_supported) and add a new line for the fragmentation target after the existing set_property calls:
set_property(TARGET fast_alloc_bench_fragmentation PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)
The CMakeLists.txt now references benchmarks/bench_fragmentation.cpp, which does not exist yet. CMake will fail to configure without it, so you need to create a minimal placeholder.
- Create a new file at benchmarks/bench_fragmentation.cpp and add this placeholder content:
#include <benchmark/benchmark.h>
// Benchmark workloads will be added in Step 3
BENCHMARK_MAIN();
Why a placeholder?
CMake checks that every source file listed in add_executable exists at configure time. This minimal file includes the Google Benchmark header and provides the BENCHMARK_MAIN() entry point so the project configures and builds cleanly. You will replace this content with real workload benchmarks in Step 3.
- Save CMakeLists.txt and benchmarks/bench_fragmentation.cpp.
Not sure where to add the new lines?
Each new block of CMake code sits inside the existing if(FASTALLOC_BUILD_BENCHMARKS) guard. If you placed a target outside that guard, CMake will error because benchmark::benchmark is only available inside the block.
Help me place the CMakeLists.txt changes correctly.
Create the AllocatorStats header
The AllocatorStats class is the instrumentation backbone of your fragmentation analysis. It uses atomic counters to track how many bytes FastAlloc has allocated, freed, and reserved, without disrupting the lock-free fast path.
- Create a new file called alloc_stats.h inside the src/ directory of your FastAlloc project.
- Copy the following code into src/alloc_stats.h:
#pragma once
#include <cstddef>
#include <cstdint>
#include <atomic>
namespace FastAlloc {
// Snapshot of allocator statistics at a point in time
struct StatsSnapshot {
int64_t live_bytes;
int64_t reserved_bytes;
int64_t alloc_count;
int64_t free_count;
double overhead_ratio;
};
// Singleton that tracks allocation, deallocation, and reservation counters
class AllocatorStats {
public:
static AllocatorStats& GetInstance() {
static AllocatorStats instance;
return instance;
}
// Record a new allocation of the given size
void RecordAlloc(std::size_t size) {
total_allocated_bytes_.fetch_add(static_cast<int64_t>(size), std::memory_order_relaxed);
alloc_count_.fetch_add(1, std::memory_order_relaxed);
}
// Record a deallocation of the given size
void RecordFree(std::size_t size) {
total_freed_bytes_.fetch_add(static_cast<int64_t>(size), std::memory_order_relaxed);
free_count_.fetch_add(1, std::memory_order_relaxed);
}
// Record newly reserved memory (e.g. from slab creation)
void RecordReserved(std::size_t size) {
total_reserved_bytes_.fetch_add(static_cast<int64_t>(size), std::memory_order_relaxed);
}
// Live bytes = total allocated minus total freed
int64_t GetLiveBytes() const {
return total_allocated_bytes_.load(std::memory_order_relaxed)
- total_freed_bytes_.load(std::memory_order_relaxed);
}
// Total memory reserved from the OS
int64_t GetReservedBytes() const {
return total_reserved_bytes_.load(std::memory_order_relaxed);
}
// Overhead ratio: reserved / live (1.0 = zero waste)
double GetOverheadRatio() const {
int64_t live = GetLiveBytes();
int64_t reserved = GetReservedBytes();
if (live <= 0 || reserved <= 0) return 0.0;
return static_cast<double>(reserved) / static_cast<double>(live);
}
// Return a consistent snapshot of all counters
StatsSnapshot GetSnapshot() const {
StatsSnapshot snap;
snap.live_bytes = GetLiveBytes();
snap.reserved_bytes = GetReservedBytes();
snap.alloc_count = alloc_count_.load(std::memory_order_relaxed);
snap.free_count = free_count_.load(std::memory_order_relaxed);
snap.overhead_ratio = GetOverheadRatio();
return snap;
}
// Reset all counters to zero (used between benchmark runs)
void Reset() {
total_allocated_bytes_.store(0, std::memory_order_relaxed);
total_freed_bytes_.store(0, std::memory_order_relaxed);
total_reserved_bytes_.store(0, std::memory_order_relaxed);
alloc_count_.store(0, std::memory_order_relaxed);
free_count_.store(0, std::memory_order_relaxed);
}
private:
AllocatorStats() = default;
std::atomic<int64_t> total_allocated_bytes_{0};
std::atomic<int64_t> total_freed_bytes_{0};
std::atomic<int64_t> total_reserved_bytes_{0};
std::atomic<int64_t> alloc_count_{0};
std::atomic<int64_t> free_count_{0};
};
} // namespace FastAlloc
What does this code do?
StatsSnapshot is a plain struct that holds a frozen copy of the counters at one moment in time. Benchmarks call GetSnapshot() to capture these values between phases.
AllocatorStats is a singleton. Every call to RecordAlloc, RecordFree, or RecordReserved bumps an atomic counter using memory_order_relaxed. Relaxed ordering is safe here because these are monotonic counters read only at benchmark boundaries, not used for synchronization between threads.
GetOverheadRatio() divides reserved bytes by live bytes. A ratio of 1.0 means zero overhead. Higher values mean the allocator is holding more memory than the application is actively using.
The implementation file is intentionally minimal because the class is header-only.
- Create a new file called alloc_stats.cpp inside the src/ directory and add this single line:
#include "alloc_stats.h"
- Save both src/alloc_stats.h and src/alloc_stats.cpp.
- Confirm both files appear in the src/ directory of your project explorer, alongside the existing fast_alloc.cpp and slab.cpp files.
Files not appearing in the right place?
Make sure alloc_stats.h and alloc_stats.cpp are directly inside the src/ folder, not inside include/ or the project root. The CMakeLists.txt references src/alloc_stats.cpp as a library source, so a misplaced file will cause a build error.
Help me figure out why my alloc_stats files are not being found by CMake.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on to the build step.
ⓧ I'd like to double check the full code
Here is the complete src/alloc_stats.h:
#pragma once
#include <cstddef>
#include <cstdint>
#include <atomic>
namespace FastAlloc {
struct StatsSnapshot {
int64_t live_bytes;
int64_t reserved_bytes;
int64_t alloc_count;
int64_t free_count;
double overhead_ratio;
};
class AllocatorStats {
public:
static AllocatorStats& GetInstance() {
static AllocatorStats instance;
return instance;
}
void RecordAlloc(std::size_t size) {
total_allocated_bytes_.fetch_add(static_cast<int64_t>(size), std::memory_order_relaxed);
alloc_count_.fetch_add(1, std::memory_order_relaxed);
}
void RecordFree(std::size_t size) {
total_freed_bytes_.fetch_add(static_cast<int64_t>(size), std::memory_order_relaxed);
free_count_.fetch_add(1, std::memory_order_relaxed);
}
void RecordReserved(std::size_t size) {
total_reserved_bytes_.fetch_add(static_cast<int64_t>(size), std::memory_order_relaxed);
}
int64_t GetLiveBytes() const {
return total_allocated_bytes_.load(std::memory_order_relaxed)
- total_freed_bytes_.load(std::memory_order_relaxed);
}
int64_t GetReservedBytes() const {
return total_reserved_bytes_.load(std::memory_order_relaxed);
}
double GetOverheadRatio() const {
int64_t live = GetLiveBytes();
int64_t reserved = GetReservedBytes();
if (live <= 0 || reserved <= 0) return 0.0;
return static_cast<double>(reserved) / static_cast<double>(live);
}
StatsSnapshot GetSnapshot() const {
StatsSnapshot snap;
snap.live_bytes = GetLiveBytes();
snap.reserved_bytes = GetReservedBytes();
snap.alloc_count = alloc_count_.load(std::memory_order_relaxed);
snap.free_count = free_count_.load(std::memory_order_relaxed);
snap.overhead_ratio = GetOverheadRatio();
return snap;
}
void Reset() {
total_allocated_bytes_.store(0, std::memory_order_relaxed);
total_freed_bytes_.store(0, std::memory_order_relaxed);
total_reserved_bytes_.store(0, std::memory_order_relaxed);
alloc_count_.store(0, std::memory_order_relaxed);
free_count_.store(0, std::memory_order_relaxed);
}
private:
AllocatorStats() = default;
std::atomic<int64_t> total_allocated_bytes_{0};
std::atomic<int64_t> total_freed_bytes_{0};
std::atomic<int64_t> total_reserved_bytes_{0};
std::atomic<int64_t> alloc_count_{0};
std::atomic<int64_t> free_count_{0};
};
} // namespace FastAlloc
And the complete src/alloc_stats.cpp:
#include "alloc_stats.h"
Build the project to confirm clean compilation
With the new files in place and CMakeLists.txt updated, you need to reconfigure and rebuild to make sure everything compiles together.
- Open a terminal in the root of your FastAlloc project directory.
- Reconfigure the CMake build by running this command:
cmake -B build -DCMAKE_BUILD_TYPE=Release
What does this command do?
The -B build flag tells CMake to use the build/ directory for generated files. If a build/ directory already exists from earlier work, CMake reconfigures it with the updated CMakeLists.txt.
During this step, CMake also downloads Google Benchmark v1.9.5 via FetchContent. This may take a minute on the first run.
- You should see CMake output ending with lines like Configuring done and Generating done with no errors.
- Build the project by running:
cmake --build build --config Release
- You should see the compiler process alloc_stats.cpp and bench_fragmentation.cpp among the build targets. The build should complete with no errors.
Seeing a build error?
If CMake reports a missing source file, double-check that benchmarks/bench_fragmentation.cpp exists and is saved. Also verify that src/alloc_stats.cpp is in the src/ folder, not in include/ or the project root.
If you see linker errors about Google Benchmark symbols, make sure the GIT_TAG was changed to v1.9.5 (not 1.9.5 without the v prefix) and that FetchContent_MakeAvailable(googlebenchmark) is still present.
Help me debug my FastAlloc build errors after adding alloc_stats and the bench_fragmentation target.
Your build infrastructure is in place. Next up, you will wire the AllocatorStats counters into FastAlloc's hot path so every allocation and deallocation gets tracked.
Instrument FastAlloc with Fragmentation Tracking
In the previous step, you set up the benchmark infrastructure and created the AllocatorStats singleton. The singleton compiles cleanly, but it is completely disconnected from FastAlloc's allocation and deallocation paths.
In this step, you will wire the stats tracking into FastAlloc's hot path so that every call to fast_malloc, fast_free, and Slab::Create records its activity. You will also expose these stats through the public API so benchmarks can query fragmentation metrics at any time.
In this step, get ready to:
- Wire RecordAlloc, RecordFree, and RecordReserved calls into FastAlloc's core functions.
- Add a fast_get_stats() function to the public API header.
- Verify the instrumentation by running the existing test suite.
Wire instrumentation calls into FastAlloc's core functions
The AllocatorStats singleton is fully implemented in the header from the previous step. Now you need to call its recording methods from three locations in the existing FastAlloc source code.
- In your IDE, open src/fast_alloc.cpp.
- Add the following include at the top of the file, alongside the existing includes:
#include "alloc_stats.h"
- Scroll down to the fast_malloc function. Add this line inside the function, right after a successful allocation (before the pointer is returned):
// Record the allocation size for fragmentation tracking
AllocatorStats::GetInstance().RecordAlloc(size);
- Now find the fast_free function in the same file. Add this line inside it, after the size of the freed block has been determined:
// Record the freed size for fragmentation tracking
AllocatorStats::GetInstance().RecordFree(size);
- Save src/fast_alloc.cpp.
- Now open src/slab.cpp.
- Add the same include at the top of src/slab.cpp:
#include "alloc_stats.h"
- Find the Slab::Create function. Add this line after the slab's memory has been allocated (where the slab size is known):
// Record the total reserved memory for this slab
AllocatorStats::GetInstance().RecordReserved(memory_size);
- Save src/slab.cpp.
Why relaxed memory ordering?
The AllocatorStats counters use std::memory_order_relaxed on every atomic operation. These counters are monotonic totals that only get read at benchmark boundaries, not used for synchronization between threads.
Relaxed ordering avoids the cache-line invalidation overhead that memory_order_seq_cst would introduce. This is critical because these calls sit on the allocator's hot path, and you do not want your instrumentation to distort the timing measurements.
- Confirm the instrumentation compiles by building the project:
cmake --build build
You should see a clean build with no errors. The compiler may emit warnings about unused variables if the stats are not yet queried, and that is fine.
Seeing a compile error?
- Check that the #include "alloc_stats.h" line appears at the top of both src/fast_alloc.cpp and src/slab.cpp. The header is in the src/ directory, which is already an include directory in your CMakeLists.txt.
- Make sure RecordAlloc and RecordFree are called on AllocatorStats::GetInstance(), not on a raw AllocatorStats object. The constructor is private.
- Verify that the size variable you pass to RecordAlloc and RecordFree is in scope at the point where you placed the call.
Help me debug a compile error after adding AllocatorStats instrumentation calls to FastAlloc.
Add the public API for stats queries
The instrumentation is recording data, but nothing outside the FastAlloc internals can read it yet. You need to expose a public function that benchmarks and tests can call to query fragmentation metrics.
- Open include/fast_alloc.h.
- Find the existing fast_realloc declaration. Add the following code after it, before the closing of the FastAlloc namespace:
/**
* @brief Returns a snapshot of allocator statistics for fragmentation analysis.
* Requires FASTALLOC_ENABLE_STATS to be defined at build time.
*/
struct AllocStatsSnapshot {
int64_t live_bytes;
int64_t reserved_bytes;
int64_t alloc_count;
int64_t free_count;
double overhead_ratio;
};
FASTALLOC_API AllocStatsSnapshot fast_get_stats();
FASTALLOC_API void fast_reset_stats();
What does this code do?
- AllocStatsSnapshot is a plain struct that holds a point-in-time snapshot of the allocator's memory accounting. It mirrors the internal StatsSnapshot but lives in the public header so benchmarks can use it without including internal headers.
- fast_get_stats() returns the current snapshot. Benchmarks call this after a workload to read overhead_ratio and live_bytes.
- fast_reset_stats() zeroes all counters. Each benchmark calls this before starting so that stats reflect only the current workload.
- Save include/fast_alloc.h.
Now you need to implement these two functions. They bridge the public API to the internal singleton.
- Switch back to src/fast_alloc.cpp. Add the following two functions at the bottom of the file, inside the FastAlloc namespace:
AllocStatsSnapshot fast_get_stats() {
// Grab the internal snapshot and copy to the public struct
auto snap = AllocatorStats::GetInstance().GetSnapshot();
AllocStatsSnapshot result;
result.live_bytes = snap.live_bytes;
result.reserved_bytes = snap.reserved_bytes;
result.alloc_count = snap.alloc_count;
result.free_count = snap.free_count;
result.overhead_ratio = snap.overhead_ratio;
return result;
}
void fast_reset_stats() {
// Zero all counters so the next benchmark starts from a clean slate
AllocatorStats::GetInstance().Reset();
}
Why two separate snapshot structs?
The internal StatsSnapshot lives in src/alloc_stats.h, which is a private implementation header. Benchmarks and tests include only include/fast_alloc.h.
The public AllocStatsSnapshot struct keeps the API boundary clean. Internal changes to the stats module (adding new counters, changing atomic types) do not break the public header.
- Save src/fast_alloc.cpp.
- Build the project to confirm everything compiles by running:
cmake --build build
You should see a clean build with no errors.
Linker error about fast_get_stats?
- Make sure fast_get_stats() and fast_reset_stats() are defined inside the FastAlloc namespace in src/fast_alloc.cpp. If they are outside the namespace, the linker will not find them.
- Confirm that src/alloc_stats.cpp is listed in the FastAlloc library sources in your CMakeLists.txt. You added it in the previous step.
Help me fix a linker error after adding fast_get_stats and fast_reset_stats to FastAlloc.
Verify the instrumentation with the existing tests
The existing fast_alloc_tests target exercises FastAlloc's allocation and deallocation paths. If your instrumentation is wired correctly, the stats counters will be non-zero after those tests run.
- Run the existing test suite by executing:
ctest --test-dir build --output-on-failure
You should see all tests pass. The test output confirms that the allocation and deallocation paths are still working correctly with the instrumentation in place.
How do I know the stats counters are actually working?
The existing tests call fast_malloc and fast_free hundreds of times across their test cases. Every call now hits RecordAlloc or RecordFree. If the tests pass without crashes or assertion failures, the atomic counters are incrementing correctly.
In the next step, your benchmark workloads will call fast_get_stats() and report the counter values as custom Google Benchmark counters. That is where you will see the actual numbers.
Tests failing or crashing?
- If a test crashes with a segfault, check that you placed the RecordAlloc call at a point where the allocation has already succeeded. Calling it before the pointer is valid means the counter increments even on failed allocations.
- If tests pass but you see unexpected counter values later, make sure RecordFree receives the same size that was originally allocated. A mismatch causes GetLiveBytes() to drift from reality.
Help me debug a test failure after adding AllocatorStats instrumentation to FastAlloc.
Your allocator is now self-aware. Every allocation, deallocation, and slab creation is tracked through atomic counters on the hot path. Next up, you will build the lifetime-aware benchmark workloads that put this instrumentation to work.
Build the Lifetime-Aware Benchmark Workloads
Your AllocatorStats instrumentation is wired in and verified. FastAlloc can now track every byte it allocates, frees, and reserves.
Pure throughput benchmarks miss the real story. Production allocators break down when short-lived objects churn through memory while long-lived objects pin slabs in place. In this step, you will build benchmark workloads that simulate these realistic lifetime patterns and report fragmentation metrics alongside timing data.
In this step, get ready to:
- Create a benchmark file with lifetime churn workloads that mix short-lived and long-lived allocations.
- Add phased workload benchmarks that cycle through RampUp, Steady, and BulkReclaim phases.
- Build and run the benchmark to verify custom fragmentation counters appear in the output.
Create the benchmark file with lifetime churn workloads
Allocator fragmentation is driven by lifetime mismatch. When short-lived objects (freed every iteration) and long-lived objects (pinned for the entire run) share the same slabs, freed blocks become unusable holes.
- Create a new file called bench_fragmentation.cpp inside your benchmarks/ folder.
- Add the includes, the shared ReportFragStats helper, and both lifetime churn benchmarks by pasting this code:
#include <benchmark/benchmark.h>
#include "fast_alloc.h"
#include <vector>
#include <random>
#include <cstdlib>
#include <cstring>
using namespace FastAlloc;
// Helper: report fragmentation counters
static void ReportFragStats(benchmark::State& state) {
auto snap = fast_get_stats();
state.counters["live_bytes"] = benchmark::Counter(
static_cast<double>(snap.live_bytes), benchmark::Counter::kDefaults);
state.counters["reserved_bytes"] = benchmark::Counter(
static_cast<double>(snap.reserved_bytes), benchmark::Counter::kDefaults);
state.counters["overhead_ratio"] = benchmark::Counter(
snap.overhead_ratio, benchmark::Counter::kDefaults);
double frag_pct = (snap.reserved_bytes > 0 && snap.live_bytes > 0)
? (1.0 - static_cast<double>(snap.live_bytes) / static_cast<double>(snap.reserved_bytes)) * 100.0
: 0.0;
state.counters["frag_pct"] = benchmark::Counter(
frag_pct, benchmark::Counter::kDefaults);
}
// ============================================================
// 1. Lifetime Churn: mixed short-lived and long-lived objects
// ============================================================
static void BM_LifetimeChurn_FastAlloc(benchmark::State& state) {
const int short_lived_count = 200;
const int long_lived_count = 50;
std::size_t small_size = 64;
std::size_t large_size = 512;
fast_reset_stats();
std::vector<void*> long_lived;
long_lived.reserve(long_lived_count);
for (auto _ : state) {
// Allocate long-lived objects (kept across iterations)
if (long_lived.empty()) {
for (int i = 0; i < long_lived_count; ++i) {
void* p = fast_malloc(large_size);
benchmark::DoNotOptimize(p);
long_lived.push_back(p);
}
}
// Short-lived churn: allocate and free within the iteration
for (int i = 0; i < short_lived_count; ++i) {
void* p = fast_malloc(small_size);
benchmark::DoNotOptimize(p);
fast_free(p);
}
}
// Clean up long-lived
for (void* p : long_lived) fast_free(p);
ReportFragStats(state);
}
BENCHMARK(BM_LifetimeChurn_FastAlloc)->Threads(1)->Threads(4)->Threads(8);
static void BM_LifetimeChurn_Std(benchmark::State& state) {
const int short_lived_count = 200;
const int long_lived_count = 50;
std::size_t small_size = 64;
std::size_t large_size = 512;
std::vector<void*> long_lived;
long_lived.reserve(long_lived_count);
for (auto _ : state) {
if (long_lived.empty()) {
for (int i = 0; i < long_lived_count; ++i) {
void* p = std::malloc(large_size);
benchmark::DoNotOptimize(p);
long_lived.push_back(p);
}
}
for (int i = 0; i < short_lived_count; ++i) {
void* p = std::malloc(small_size);
benchmark::DoNotOptimize(p);
std::free(p);
}
}
for (void* p : long_lived) std::free(p);
}
BENCHMARK(BM_LifetimeChurn_Std)->Threads(1)->Threads(4)->Threads(8);
What does this code do?
ReportFragStats is a shared helper that reads the allocator's stats snapshot and writes four custom counters into Google Benchmark's output: live_bytes, reserved_bytes, overhead_ratio, and frag_pct. Every benchmark you write calls this at the end to attach fragmentation data to the timing results.
BM_LifetimeChurn_FastAlloc allocates 50 long-lived objects (512 bytes each, kept across iterations) and churns 200 short-lived objects (64 bytes, freed every iteration). BM_LifetimeChurn_Std runs the identical workload against std::malloc/std::free for side-by-side throughput comparison. Both register with Threads(1), Threads(4), and Threads(8) to show how contention affects both allocators.
Why lifetime-aware workloads instead of uniform alloc/free loops?
Uniform benchmarks (allocate N blocks, free N blocks) start on a clean heap, which unfairly advantages general-purpose allocators. Real applications have objects with different lifetimes coexisting in memory.
The lifetime churn pattern forces the allocator to manage a fragmented heap. Long-lived objects prevent entire slabs from being reclaimed, while short-lived churn creates holes that may or may not be reusable.
- Save bench_fragmentation.cpp.
- You won't see a change yet. The next substep adds the phased workload benchmarks, and then you will build and run everything together.
Add the phased workload benchmarks
The lifetime churn benchmark measures fragmentation under sustained mixed-lifetime pressure. The phased workload takes a different angle: it walks through three distinct allocation phases that mirror a real application's lifecycle.
- In bench_fragmentation.cpp, add the following code below the BENCHMARK(BM_LifetimeChurn_Std) line:
// ============================================================
// 2. Phased Workload: RampUp -> Steady -> BulkReclaim
// ============================================================
static void BM_PhasedWorkload_FastAlloc(benchmark::State& state) {
const int ramp_count = 500;
const int steady_cycles = 300;
std::size_t block_size = static_cast<std::size_t>(state.range(0));
fast_reset_stats();
for (auto _ : state) {
std::vector<void*> pool;
pool.reserve(ramp_count);
// Phase 1: RampUp -- allocate steadily
for (int i = 0; i < ramp_count; ++i) {
void* p = fast_malloc(block_size);
benchmark::DoNotOptimize(p);
pool.push_back(p);
}
// Phase 2: Steady -- allocate and free at equal rate
std::mt19937 rng(42 + state.thread_index());
for (int i = 0; i < steady_cycles; ++i) {
// Free a random existing allocation
std::size_t idx = rng() % pool.size();
fast_free(pool[idx]);
// Replace with a new allocation
pool[idx] = fast_malloc(block_size);
benchmark::DoNotOptimize(pool[idx]);
}
// Phase 3: BulkReclaim -- free everything
for (void* p : pool) fast_free(p);
}
ReportFragStats(state);
}
BENCHMARK(BM_PhasedWorkload_FastAlloc)->Arg(32)->Arg(128)->Arg(512)->Arg(2048)
->Threads(1)->Threads(4)->Threads(8);
static void BM_PhasedWorkload_Std(benchmark::State& state) {
const int ramp_count = 500;
const int steady_cycles = 300;
std::size_t block_size = static_cast<std::size_t>(state.range(0));
for (auto _ : state) {
std::vector<void*> pool;
pool.reserve(ramp_count);
for (int i = 0; i < ramp_count; ++i) {
void* p = std::malloc(block_size);
benchmark::DoNotOptimize(p);
pool.push_back(p);
}
std::mt19937 rng(42 + state.thread_index());
for (int i = 0; i < steady_cycles; ++i) {
std::size_t idx = rng() % pool.size();
std::free(pool[idx]);
pool[idx] = std::malloc(block_size);
benchmark::DoNotOptimize(pool[idx]);
}
for (void* p : pool) std::free(p);
}
}
BENCHMARK(BM_PhasedWorkload_Std)->Arg(32)->Arg(128)->Arg(512)->Arg(2048)
->Threads(1)->Threads(4)->Threads(8);
BENCHMARK_MAIN();
What does the phased workload measure?
The three phases model a real application's memory lifecycle:
- RampUp allocates 500 blocks steadily. This fills slabs and forces the allocator to commit fresh memory.
- Steady runs 300 cycles where a random existing allocation is freed and immediately replaced. This creates the fragmented heap state that clean-heap benchmarks miss entirely.
- BulkReclaim frees everything. The fragmentation counters captured at the end reveal how much reserved memory the allocator held beyond what was actually live during the steady phase.
The Arg(32)->Arg(128)->Arg(512)->Arg(2048) chain runs the same workload at four different block sizes. Smaller blocks pack more objects per slab, so fragmentation patterns differ from larger blocks that may each occupy their own slab.
- Save bench_fragmentation.cpp.
- You won't see a change yet. The next substep builds and runs the benchmark to confirm everything works.
Seeing a red squiggly under state.thread_index()?
Some IDE IntelliSense configurations cannot resolve Google Benchmark headers until CMake runs. The code will compile correctly in the next substep. If the error persists after building, make sure your IDE's CMake integration has refreshed the project configuration.
help me fix IntelliSense errors for Google Benchmark headers in my FastAlloc project
✔️ Awesome, I've got everything!
Make sure you have saved bench_fragmentation.cpp. The file should end with BENCHMARK_MAIN();.
ⓧ I'd like to double check the full code
#include <benchmark/benchmark.h>
#include "fast_alloc.h"
#include <vector>
#include <random>
#include <cstdlib>
#include <cstring>
using namespace FastAlloc;
// Helper: report fragmentation counters
static void ReportFragStats(benchmark::State& state) {
auto snap = fast_get_stats();
state.counters["live_bytes"] = benchmark::Counter(
static_cast<double>(snap.live_bytes), benchmark::Counter::kDefaults);
state.counters["reserved_bytes"] = benchmark::Counter(
static_cast<double>(snap.reserved_bytes), benchmark::Counter::kDefaults);
state.counters["overhead_ratio"] = benchmark::Counter(
snap.overhead_ratio, benchmark::Counter::kDefaults);
double frag_pct = (snap.reserved_bytes > 0 && snap.live_bytes > 0)
? (1.0 - static_cast<double>(snap.live_bytes) / static_cast<double>(snap.reserved_bytes)) * 100.0
: 0.0;
state.counters["frag_pct"] = benchmark::Counter(
frag_pct, benchmark::Counter::kDefaults);
}
// ============================================================
// 1. Lifetime Churn: mixed short-lived and long-lived objects
// ============================================================
static void BM_LifetimeChurn_FastAlloc(benchmark::State& state) {
const int short_lived_count = 200;
const int long_lived_count = 50;
std::size_t small_size = 64;
std::size_t large_size = 512;
fast_reset_stats();
std::vector<void*> long_lived;
long_lived.reserve(long_lived_count);
for (auto _ : state) {
// Allocate long-lived objects (kept across iterations)
if (long_lived.empty()) {
for (int i = 0; i < long_lived_count; ++i) {
void* p = fast_malloc(large_size);
benchmark::DoNotOptimize(p);
long_lived.push_back(p);
}
}
// Short-lived churn: allocate and free within the iteration
for (int i = 0; i < short_lived_count; ++i) {
void* p = fast_malloc(small_size);
benchmark::DoNotOptimize(p);
fast_free(p);
}
}
// Clean up long-lived
for (void* p : long_lived) fast_free(p);
ReportFragStats(state);
}
BENCHMARK(BM_LifetimeChurn_FastAlloc)->Threads(1)->Threads(4)->Threads(8);
static void BM_LifetimeChurn_Std(benchmark::State& state) {
const int short_lived_count = 200;
const int long_lived_count = 50;
std::size_t small_size = 64;
std::size_t large_size = 512;
std::vector<void*> long_lived;
long_lived.reserve(long_lived_count);
for (auto _ : state) {
if (long_lived.empty()) {
for (int i = 0; i < long_lived_count; ++i) {
void* p = std::malloc(large_size);
benchmark::DoNotOptimize(p);
long_lived.push_back(p);
}
}
for (int i = 0; i < short_lived_count; ++i) {
void* p = std::malloc(small_size);
benchmark::DoNotOptimize(p);
std::free(p);
}
}
for (void* p : long_lived) std::free(p);
}
BENCHMARK(BM_LifetimeChurn_Std)->Threads(1)->Threads(4)->Threads(8);
// ============================================================
// 2. Phased Workload: RampUp -> Steady -> BulkReclaim
// ============================================================
static void BM_PhasedWorkload_FastAlloc(benchmark::State& state) {
const int ramp_count = 500;
const int steady_cycles = 300;
std::size_t block_size = static_cast<std::size_t>(state.range(0));
fast_reset_stats();
for (auto _ : state) {
std::vector<void*> pool;
pool.reserve(ramp_count);
// Phase 1: RampUp -- allocate steadily
for (int i = 0; i < ramp_count; ++i) {
void* p = fast_malloc(block_size);
benchmark::DoNotOptimize(p);
pool.push_back(p);
}
// Phase 2: Steady -- allocate and free at equal rate
std::mt19937 rng(42 + state.thread_index());
for (int i = 0; i < steady_cycles; ++i) {
// Free a random existing allocation
std::size_t idx = rng() % pool.size();
fast_free(pool[idx]);
// Replace with a new allocation
pool[idx] = fast_malloc(block_size);
benchmark::DoNotOptimize(pool[idx]);
}
// Phase 3: BulkReclaim -- free everything
for (void* p : pool) fast_free(p);
}
ReportFragStats(state);
}
BENCHMARK(BM_PhasedWorkload_FastAlloc)->Arg(32)->Arg(128)->Arg(512)->Arg(2048)
->Threads(1)->Threads(4)->Threads(8);
static void BM_PhasedWorkload_Std(benchmark::State& state) {
const int ramp_count = 500;
const int steady_cycles = 300;
std::size_t block_size = static_cast<std::size_t>(state.range(0));
for (auto _ : state) {
std::vector<void*> pool;
pool.reserve(ramp_count);
for (int i = 0; i < ramp_count; ++i) {
void* p = std::malloc(block_size);
benchmark::DoNotOptimize(p);
pool.push_back(p);
}
std::mt19937 rng(42 + state.thread_index());
for (int i = 0; i < steady_cycles; ++i) {
std::size_t idx = rng() % pool.size();
std::free(pool[idx]);
pool[idx] = std::malloc(block_size);
benchmark::DoNotOptimize(pool[idx]);
}
for (void* p : pool) std::free(p);
}
}
BENCHMARK(BM_PhasedWorkload_Std)->Arg(32)->Arg(128)->Arg(512)->Arg(2048)
->Threads(1)->Threads(4)->Threads(8);
BENCHMARK_MAIN();
Build and run the fragmentation benchmarks
The benchmark file is complete. Time to compile it and confirm that your custom fragmentation counters appear alongside the standard timing output.
- Build the new benchmark target by running this command from the FastAlloc project root:
cmake --build build --config Release --target fast_alloc_bench_fragmentation
What does this command do?
The --target fast_alloc_bench_fragmentation flag tells CMake to build only the new fragmentation benchmark, skipping the existing throughput benchmarks and tests. The --config Release flag enables compiler optimizations so benchmark timings reflect real performance.
- The build should complete without errors.
Build fails with unresolved symbols?
If you see linker errors about fast_get_stats or fast_reset_stats, make sure those functions are implemented in your fast_alloc.cpp and that alloc_stats.cpp is listed in the FastAlloc library sources in your CMakeLists.txt. Both were added in Step 2.
If your CMake cache is stale, reconfigure first by running cmake -B build -DCMAKE_BUILD_TYPE=Release and then retry the build command.
help me fix linker errors when building my FastAlloc fragmentation benchmarks
- Once the build succeeds, run the benchmark:
macOS
./build/fast_alloc_bench_fragmentation
Windows
.\build\Release\fast_alloc_bench_fragmentation.exe
You should see benchmark output in your terminal with the standard columns (name, time, CPU, iterations) plus your custom counters: live_bytes, reserved_bytes, overhead_ratio, and frag_pct appended to each FastAlloc benchmark row.
What should I see?
The BM_LifetimeChurn_FastAlloc and BM_PhasedWorkload_FastAlloc rows will show your custom counters as key-value pairs at the end of each line. The overhead_ratio value tells you how many bytes FastAlloc reserved for every byte that was actually live. A value of 1.0 means zero overhead.
The BM_LifetimeChurn_Std and BM_PhasedWorkload_Std rows will only show timing data (no custom counters) because the system allocator is not instrumented. The side-by-side comparison is purely on throughput for now.
Custom counters not appearing?
If the benchmark runs but you only see timing columns without live_bytes or overhead_ratio, check that ReportFragStats(state) is called at the end of each FastAlloc benchmark function (after the benchmark loop, not inside it).
Also verify that fast_reset_stats() is called at the start of each FastAlloc benchmark. Without the reset, counters accumulate across benchmark iterations and produce misleading values.
help me debug missing custom counters in my Google Benchmark fragmentation output
Your fragmentation benchmarks are running and reporting custom counters. Next up, you will add JSON output to create portfolio-ready performance reports.
Add JSON Output and Portfolio-Ready Reporting
Your benchmarks now produce fragmentation metrics in the console. That output disappears the moment you close the terminal.
Portfolio-quality performance claims need structured, machine-readable evidence you can reference months later. In this step, you will export the benchmark results to a JSON file and verify that the side-by-side FastAlloc vs. std::malloc comparison captures both throughput and fragmentation metrics.
In this step, get ready to:
- Export benchmark results to JSON with custom fragmentation counters.
- Verify FastAlloc vs. system allocator comparison data in the JSON output.
Run the benchmarks with JSON output
Google Benchmark supports writing structured results to a file through the --benchmark_out flag. The file output format defaults to JSON, so every custom counter you added in the previous step will appear as a field on each benchmark entry.
- Create the output directory for your results by running this command:
mkdir -p results
Windows users
If you are on Windows, use mkdir results instead. The -p flag is a Unix option that prevents errors if the directory already exists.
- Confirm the directory exists by running:
ls results
You should see an empty directory listed with no errors.
- Run the fragmentation benchmarks with JSON output by running this command:
./build/fast_alloc_bench_fragmentation --benchmark_format=json --benchmark_out=results/fragmentation_report.json
What do these flags do?
- --benchmark_format=json switches the console output to JSON format so you can see the structured data live as the benchmarks run.
- --benchmark_out=results/fragmentation_report.json writes a copy of the results to the specified file. The file output format defaults to JSON automatically.
The benchmarks will take a minute or two to complete across all thread counts and block sizes.
- After the run finishes, confirm the JSON file was created by running:
ls -lh results/fragmentation_report.json
You should see the file listed with a non-zero file size.
File not found or empty?
- Make sure you created the results/ directory before running the benchmark. Google Benchmark does not create parent directories for --benchmark_out automatically.
- Check that the benchmark binary path matches your build output location. If you used a different build directory name, adjust the path accordingly.
- help me figure out why my benchmark JSON output file was not created
Verify custom counters and comparison data in the JSON output
The JSON file contains every benchmark variant your suite runs. The critical thing to verify is that your fragmentation counters appear alongside the standard timing fields, and that both FastAlloc and std::malloc results sit side-by-side.
- Inspect the JSON output to find your custom counters by running:
cat results/fragmentation_report.json | head -80
What should I see in the JSON?
The JSON file has two top-level keys: context (CPU info, date, build type) and benchmarks (an array of result objects).
Each benchmark entry includes standard fields like name, iterations, real_time, and cpu_time. Your custom counters appear as additional fields: overhead_ratio, live_bytes, reserved_bytes, and frag_pct.
- Search for the comparison benchmark entries by running:
grep -c "BM_LifetimeChurn_Std" results/fragmentation_report.json
You should see a count of 3 or more (one entry per thread count: 1, 4, and 8).
- Run the same check for the phased workload comparison by running:
grep -c "BM_PhasedWorkload_Std" results/fragmentation_report.json
You should see 12 or more entries (one per combination of block size and thread count).
Why does the side-by-side comparison matter?
The BM_LifetimeChurn_Std and BM_PhasedWorkload_Std benchmarks run identical workloads against std::malloc and std::free. Because they share the same benchmark names minus the suffix, the JSON output naturally groups FastAlloc and system allocator results next to each other.
Anyone reading your portfolio report gets an apples-to-apples comparison on both throughput (timing) and memory efficiency (fragmentation counters) without any additional setup.
Custom counters missing from the JSON?
- Verify that ReportFragStats(state) is called at the end of each _FastAlloc benchmark function in benchmarks/bench_fragmentation.cpp. The _Std variants do not call it because they use std::malloc which does not report to AllocatorStats.
- Make sure you rebuilt the project after any changes to the benchmark file.
- help me debug why my custom counters are not appearing in the benchmark JSON output
Your FastAlloc project now has machine-readable benchmark evidence documenting both throughput and fragmentation behavior. Next up, push the boundaries further with a cross-thread fragmentation stress test in the Secret Mission.
Secret mission
Cross-Thread Fragmentation Stress Test
Your existing benchmarks measure fragmentation when threads allocate and free their own objects. In production, objects are often allocated on one thread and freed by a completely different one. Can you build a cross-thread stress test that exposes how this asymmetric pattern impacts FastAlloc's slab utilization through the MPSC pending queue?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with no cloud resources or paid services. There are no ongoing costs to worry about.
Decide whether to keep your new benchmark infrastructure as a permanent portfolio addition, clean up build artifacts to free disk space, or remove everything entirely.
Resources you used:
- Build artifacts in the build/ directory (compiled binaries, the fetched Google Benchmark library).
- New source files: src/alloc_stats.h, src/alloc_stats.cpp, and benchmarks/bench_fragmentation.cpp.
- Benchmark output: results/fragmentation_report.json.
- Modifications to existing files: CMakeLists.txt, include/fast_alloc.h, and src/fast_alloc.cpp.
Keep everything running
No action needed. Choose this if you want the new benchmark files, stats module, and JSON output to stay as permanent additions to your FastAlloc portfolio project.
- Your AllocatorStats instrumentation, lifetime-aware benchmarks, and JSON fragmentation report remain ready to run at any time.
- Build artifacts in build/ may use several hundred MB of disk space (mostly the compiled Google Benchmark library). This is normal for a CMake FetchContent dependency.
Pause - I'll come back to this later
Remove compiled binaries to free disk space. All source files and benchmark results stay intact so you can rebuild later.
- Clean the compiled binaries by running this from your FastAlloc project root:
cmake --build build --target clean
What does this remove?
This removes compiled object files, libraries, and benchmark executables from the build/ directory. The CMake configuration and cached Google Benchmark source remain, so your next build is faster.
- To rebuild later, run your normal CMake build command from the project root. Google Benchmark recompiles automatically.
Delete - I don't want to use this again
Remove all files and changes added in this project to restore your FastAlloc repository to its pre-upgrade state.
- Delete the build directory, new source files, and benchmark output by running the commands for your platform:
macOS
rm -rf build/
rm src/alloc_stats.h src/alloc_stats.cpp
rm benchmarks/bench_fragmentation.cpp
rm results/fragmentation_report.json
Windows
rmdir /s /q build
del src\alloc_stats.h src\alloc_stats.cpp
del benchmarks\bench_fragmentation.cpp
del results\fragmentation_report.json
- Revert the three modified files to their original state by running:
git checkout -- CMakeLists.txt include/fast_alloc.h src/fast_alloc.cpp
What does this git command do?
This restores CMakeLists.txt, include/fast_alloc.h, and src/fast_alloc.cpp to their last committed version. This only works if you have not yet committed the changes from this project.
If you already committed, use git log to find the commit before your upgrades, then git revert that commit instead.
Nice Work!
Nice Work!
Nice work! You just upgraded FastAlloc from a basic allocator project into one with production-grade fragmentation instrumentation and a professional benchmark suite.
You've learned how to:
- Built an AllocatorStats instrumentation module with relaxed atomic counters that tracks live bytes, reserved bytes, and overhead ratio inside FastAlloc without impacting the lock-free fast path.
- Created lifetime-aware benchmark workloads using churn and phased patterns (RampUp, Steady, BulkReclaim) that reveal fragmentation behavior invisible to pure throughput tests.
- Upgraded the Google Benchmark integration to v1.9.5 with custom counters, side-by-side std::malloc comparison benchmarks, and JSON output for portfolio-ready performance reporting.
- Secret Mission: Built a cross-thread fragmentation stress test that exercises the MPSC pending queue and measures slab utilization under asymmetric producer/consumer allocation patterns.
Ready to quiz yourself?