Skip to content

Commit 9bef107

Browse files
committed
feat: enhance benchmark capabilities with customizable stack depth and improved throughput chart
1 parent ed6f877 commit 9bef107

6 files changed

Lines changed: 189 additions & 86 deletions

File tree

135 KB
Loading

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,5 @@ fuzzers/*_fuzzer
6767
# Python
6868
.venv
6969

70-
benchmark
70+
benchmark
71+
benchmark_*

Makefile

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,12 @@ FUZZ_FLAGS = -fsanitize=fuzzer,address,undefined -O3 -g3 -fno-omit-frame-pointer
6969
FUZZ_DEBUG_FLAGS = -fsanitize=fuzzer,address,undefined -O0 -g3 -fno-omit-frame-pointer -DESTACK_FUZZ_DEBUG -DDEBUG
7070

7171
# --- Benchmark Configuration ---
72+
DEPTH ?= 15
7273
BENCH_DIR = bench
7374
BENCH_SRCS = $(BENCH_DIR)/benchmark.cpp $(BENCH_DIR)/Allocator.cpp $(BENCH_DIR)/StackAllocator.cpp
74-
BENCH_BIN = $(BENCH_DIR)/benchmark
75+
BENCH_BIN = $(BENCH_DIR)/benchmark_$(DEPTH)
7576
CXX ?= g++
76-
CXXFLAGS = -O3 -std=c++17 -flto -DNDEBUG -Wno-stringop-overflow -I. -I$(BENCH_DIR)
77+
CXXFLAGS = -O3 -std=c++17 -flto -DNDEBUG -Wno-stringop-overflow -DBENCH_DEPTH=$(DEPTH) -I. -I$(BENCH_DIR)
7778

7879
# Define the primary source file to check coverage for.
7980
COVERAGE_SRC = easy_stack.h
@@ -278,11 +279,11 @@ clean_matrix:
278279

279280
# --- Benchmark Target ---
280281
$(BENCH_BIN): $(BENCH_SRCS) easy_stack.h
281-
@printf "Compiling benchmark suite: $@\n"
282+
@printf "Compiling benchmark suite (depth $(DEPTH)): $@\n"
282283
@$(CXX) $(CXXFLAGS) $(BENCH_SRCS) -o $@
283284

284285
bench: $(BENCH_BIN)
285-
@printf "\n--- Running Stack Allocator Benchmarks ---\n"
286+
@printf "\n--- Running Stack Allocator Benchmarks (Depth: $(DEPTH)) ---\n"
286287
@./$(BENCH_BIN)
287288

288289

@@ -295,7 +296,7 @@ clean:
295296
rm -f coverage.info
296297
rm -f $(FUZZ_BINS) $(FUZZ_DEBUG_BINS)
297298
rm -rf $(MATRIX_DIR)
298-
rm -f $(BENCH_BIN) # Clean benchmark binary
299+
rm -f $(BENCH_DIR)/benchmark_* # Clean all benchmark binaries
299300

300301

301302
# --- Fuzzing Targets ---
@@ -330,7 +331,7 @@ list:
330331
@printf " make tests - run all tests without debug output \n"
331332
@printf " make tests_full - run all tests with debug output\n"
332333
@printf " make test_matrix -j$(nproc) - run matrix of tests\n"
333-
@printf " make bench - compile and run stupidly fast allocator benchmarks\n"
334+
@printf " make bench [DEPTH=15|30|100] - compile and run benchmarks with customizable stack depth (default: 15)\n"
334335
@printf " make coverage - build & run tests to generate coverage data for CodeCov\n"
335336
@printf " make fuzz_[name] - run the 'core' fuzzer for 5 minutes (auto-detects fuzz_*.c)\n"
336337
@printf " make replay_[name] CRASH=... - replay a specific crash file with ASCII visualization\n"

README.md

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -124,30 +124,52 @@ Traditional stack allocators suffix or prefix each payload with inline metadata
124124

125125
## Benchmarks & Performance
126126

127-
To verify execution speed and memory overhead, `easy_stack` was benchmarked against popular alternatives:
127+
To verify execution speed, cache resilience, and scalability under load, `easy_stack` was benchmarked against popular alternatives:
128128
* **wb_alloc (Bundy):** A widely-used, minimalist C arena allocator.
129129
* **Trebi StackAllocator:** A standard C++ LIFO stack allocator (compiled with `-flto` for maximum devirtualization).
130130

131131
### Test Environment
132132
* **CPU:** AMD Ryzen 7 4700U (8 Cores / 8 Threads, Zen 2 @ up to 4.1 GHz)
133133
* **Compiler:** GCC 15.2 with `-O3 -flto -DNDEBUG`
134-
* **Scenario:** 2,000,000 iterations per run (executing exactly 72,000,000 allocator operations) of nested allocations (up to depth 15) with randomized sizes (16-160 bytes). Best of 25 runs.
134+
* **Scenario:** 2,000,000 iterations per run (executing up to 480,000,000 allocator operations) of nested allocations with randomized sizes (16-160 bytes) scaled across three distinct stack allocation depths (15, 30, and 100). Best of 25 runs.
135135

136-
### 1. Throughput (Speed)
136+
### 1. Throughput & Cache-Line Scaling (Speed)
137137

138-
Even when configured with full runtime safety checks (`ESTACK_POLICY_DEFENSIVE`), `easy_stack` easily outperforms competitors due to its dense L1-cache friendly metadata layout. When compiled in trusted mode (`ESTACK_POLICY_CONTRACT`), it achieves near-hardware limits.
138+
Rather than benchmarking only a single shallow depth, the suite tests scaling across three critical architectural boundaries. This highlights how cache layout and processor prefetching impact execution speed as stack depth increases.
139139

140140
<p align="center">
141141
<img src=".github/assets/throughput_chart.png" width="700" alt="Throughput Comparison" />
142142
</p>
143143

144-
* **EasyStack (Contract):** **820 Million ops/sec** (~1.22 ns per allocation/free cycle) — **3.56x faster** than traditional implementations.
145-
* **EasyStack (Defensive):** **470 Million ops/sec** (~2.12 ns per cycle) — still **2.06x faster** than competitors, while providing full runtime LIFO safety and validation.
144+
#### Architectural Insights from the Results:
145+
146+
* **Perfect L1 Residency (Depth 15):**
147+
At shallow depths, the EStack header (16 bytes) and active metadata cells (30 bytes for `uint16_t` offsets) fit entirely within a single **64-byte L1 cache line** ($16 + 30 = 46$ bytes). In **Contract** (Trusted) mode, this achieves near-hardware limits of **821 Million ops/sec** (~1.22 ns per allocation/free cycle) due to 100% L1 cache hits.
148+
149+
* **L1 Cache Line Transitions (Depth 30):**
150+
As the stack depth crosses the 64-byte boundary ($16 + 60 = 76$ bytes), metadata spans into a second cache line. This transition introduces a minor **6% throughput degradation** (dropping to **766 Million ops/sec**), representing the hardware overhead of fetching the adjacent cache line.
151+
152+
* **Hardware Prefetcher Synergy (Depth 100):**
153+
At extreme depths, metadata spans 4 distinct cache lines (216 bytes total). Thanks to the dense, sequential, and contiguous layout of the metadata array, the CPU's **Hardware Prefetcher** instantly recognizes the linear access pattern. It proactively loads upcoming cache lines into L1, maintaining a blistering speed of **682 Million ops/sec** (only a 17% drop from peak L1 residency).
154+
155+
* **Instruction Latency Hiding (Defensive Mode Flatness):**
156+
In **Defensive** (Safety) mode, throughput remains completely flat at a rock-solid **~464 Million ops/sec** across all depths (15, 30, and 100).
157+
158+
This stability is a textbook demonstration of **CPU-bound execution masking memory latency**. The execution of defensive `if` branches and boundary checks occupies the pipeline with ALU instructions. During this instruction execution window, the CPU's prefetcher asynchronously pulls next-cache-line memory requests in the background. By the time the safety checks are complete, the memory is already waiting in L1, resulting in a **zero-cycle memory stall** regardless of stack depth.
159+
160+
#### Comparison to Competitors:
161+
162+
Traditional inline-header allocators degrade more severely or remain slow under depth scaling.
163+
* **Trebi (C++)** drops from **199 Million ops/sec** down to **185 Million** at depth 100. This is because inline metadata scatters memory records across the buffer ($100 \times 16 \text{ bytes} = 1600 \text{ bytes}$ of headers mixed with payload). This layout thrashes L1 cache lines with hard-to-predict address jumps, causing frequent processor stalls.
164+
* Even with full safety checks active, **EasyStack (Defensive)** remains **87% faster than wb_alloc** and **150% faster than Trebi** at depth 100, while **EasyStack (Contract)** outperforms competitors by up to **268%**.
165+
146166

147167
### 2. Memory Efficiency (Payload vs. Overhead)
148168

149169
Traditional stack allocators prefix each block with a fixed 16-byte inline header (on 64-bit systems). On small allocations (common for temporary stacks), this inline overhead consumes up to **50%+ of your buffer**.
170+
150171
`easy_stack` uses dynamically scaled metadata (only 2 bytes per allocation for buffers < 64KB). Since metadata is segregated from aligned payloads, **zero bytes are wasted on alignment padding in the control zone**.
172+
151173
Below is a comparison of usable payload space in a **10 KB buffer** across different allocation sizes:
152174

153175
<p align="center">

bench/benchmark.cpp

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@
77
#include <algorithm>
88
#include <cstring> // Added for std::memset
99

10+
// Define default depth if not passed via compiler options (e.g., -DBENCH_DEPTH=30)
11+
#ifndef BENCH_DEPTH
12+
#define BENCH_DEPTH 15
13+
#endif
14+
15+
// Compile-time scaling of benchmark execution limits
16+
constexpr int DEPTH_MAX = BENCH_DEPTH;
17+
constexpr int DEPTH_P1 = (DEPTH_MAX * 8) / 15;
18+
constexpr int DEPTH_P2 = (DEPTH_MAX * 5) / 15;
19+
constexpr double OPS_PER_ITERATION = 2.0 * (DEPTH_P1 - DEPTH_P2 + DEPTH_MAX);
20+
1021
// Compiler barrier configuration to avoid optimization folds (call elimination)
1122
#if defined(__GNUC__) || defined(__clang__)
1223
#define COMPILER_BARRIER() asm volatile("" : : : "memory")
@@ -122,12 +133,12 @@ int main() {
122133
// Fresh initialization per round to isolate state
123134
EStack* stack = estack_create_static(backing_easy, STACK_SIZE);
124135
uint32_t r_idx = 0;
125-
void* ptrs[15] = { nullptr };
136+
void* ptrs[DEPTH_MAX] = { nullptr };
126137

127138
auto start = std::chrono::high_resolution_clock::now();
128139
for (int i = 0; i < pattern_iterations; i++) {
129-
// Phase 1: Allocate 8 blocks (Depth 0 -> 8)
130-
for (int j = 0; j < 8; j++) {
140+
// Phase 1: Allocate blocks (Depth 0 -> DEPTH_P1)
141+
for (int j = 0; j < DEPTH_P1; j++) {
131142
size_t sz = 16 + (rand_pool[r_idx++ & (RAND_POOL_SIZE - 1)] % 64);
132143
ptrs[j] = estack_alloc(stack, sz);
133144
if (!ptrs[j]) {
@@ -138,15 +149,15 @@ int main() {
138149
COMPILER_BARRIER();
139150
}
140151

141-
// Phase 2: Pop last 3 blocks (Depth 8 -> 5)
142-
for (int j = 7; j >= 5; j--) {
152+
// Phase 2: Pop last blocks (Depth DEPTH_P1 -> DEPTH_P2)
153+
for (int j = DEPTH_P1 - 1; j >= DEPTH_P2; j--) {
143154
estack_free(stack, ptrs[j]);
144155
COMPILER_BARRIER();
145156
ptrs[j] = nullptr;
146157
}
147158

148-
// Phase 3: Allocate 10 more blocks (Depth 5 -> 15)
149-
for (int j = 5; j < 15; j++) {
159+
// Phase 3: Allocate more blocks (Depth DEPTH_P2 -> DEPTH_MAX)
160+
for (int j = DEPTH_P2; j < DEPTH_MAX; j++) {
150161
size_t sz = 32 + (rand_pool[r_idx++ & (RAND_POOL_SIZE - 1)] % 128);
151162
ptrs[j] = estack_alloc(stack, sz);
152163
if (!ptrs[j]) {
@@ -157,8 +168,8 @@ int main() {
157168
COMPILER_BARRIER();
158169
}
159170

160-
// Phase 4: Free all remaining blocks (Depth 15 -> 0)
161-
for (int j = 14; j >= 0; j--) {
171+
// Phase 4: Free all remaining blocks (Depth DEPTH_MAX -> 0)
172+
for (int j = DEPTH_MAX - 1; j >= 0; j--) {
162173
estack_free(stack, ptrs[j]);
163174
COMPILER_BARRIER();
164175
ptrs[j] = nullptr;
@@ -174,12 +185,12 @@ int main() {
174185
wb_MemoryArena wb_stack;
175186
wb_arenaFixedSizeInit(&wb_stack, backing_wb, STACK_SIZE, wb_Arena_Stack);
176187
uint32_t r_idx = 0;
177-
void* ptrs[15] = { nullptr };
188+
void* ptrs[DEPTH_MAX] = { nullptr };
178189

179190
auto start = std::chrono::high_resolution_clock::now();
180191
for (int i = 0; i < pattern_iterations; i++) {
181-
// Phase 1: Allocate 8 blocks (Depth 0 -> 8)
182-
for (int j = 0; j < 8; j++) {
192+
// Phase 1: Allocate blocks (Depth 0 -> DEPTH_P1)
193+
for (int j = 0; j < DEPTH_P1; j++) {
183194
size_t sz = 16 + (rand_pool[r_idx++ & (RAND_POOL_SIZE - 1)] % 64);
184195
ptrs[j] = wb_arenaPush(&wb_stack, sz);
185196
if (!ptrs[j]) {
@@ -190,15 +201,15 @@ int main() {
190201
COMPILER_BARRIER();
191202
}
192203

193-
// Phase 2: Pop last 3 blocks (Depth 8 -> 5)
194-
for (int j = 7; j >= 5; j--) {
204+
// Phase 2: Pop last blocks (Depth DEPTH_P1 -> DEPTH_P2)
205+
for (int j = DEPTH_P1 - 1; j >= DEPTH_P2; j--) {
195206
wb_arenaPop(&wb_stack);
196207
COMPILER_BARRIER();
197208
ptrs[j] = nullptr;
198209
}
199210

200-
// Phase 3: Allocate 10 more blocks (Depth 5 -> 15)
201-
for (int j = 5; j < 15; j++) {
211+
// Phase 3: Allocate more blocks (Depth DEPTH_P2 -> DEPTH_MAX)
212+
for (int j = DEPTH_P2; j < DEPTH_MAX; j++) {
202213
size_t sz = 32 + (rand_pool[r_idx++ & (RAND_POOL_SIZE - 1)] % 128);
203214
ptrs[j] = wb_arenaPush(&wb_stack, sz);
204215
if (!ptrs[j]) {
@@ -209,8 +220,8 @@ int main() {
209220
COMPILER_BARRIER();
210221
}
211222

212-
// Phase 4: Free all remaining blocks (Depth 15 -> 0)
213-
for (int j = 14; j >= 0; j--) {
223+
// Phase 4: Free all remaining blocks (Depth DEPTH_MAX -> 0)
224+
for (int j = DEPTH_MAX - 1; j >= 0; j--) {
214225
wb_arenaPop(&wb_stack);
215226
COMPILER_BARRIER();
216227
ptrs[j] = nullptr;
@@ -225,12 +236,12 @@ int main() {
225236
StackAllocator trebi_stack(STACK_SIZE);
226237
trebi_stack.Init();
227238
uint32_t r_idx = 0;
228-
void* ptrs[15] = { nullptr };
239+
void* ptrs[DEPTH_MAX] = { nullptr };
229240

230241
auto start = std::chrono::high_resolution_clock::now();
231242
for (int i = 0; i < pattern_iterations; i++) {
232-
// Phase 1: Allocate 8 blocks (Depth 0 -> 8)
233-
for (int j = 0; j < 8; j++) {
243+
// Phase 1: Allocate blocks (Depth 0 -> DEPTH_P1)
244+
for (int j = 0; j < DEPTH_P1; j++) {
234245
size_t sz = 16 + (rand_pool[r_idx++ & (RAND_POOL_SIZE - 1)] % 64);
235246
ptrs[j] = trebi_stack.Allocate(sz, 8);
236247
if (!ptrs[j]) {
@@ -241,15 +252,15 @@ int main() {
241252
COMPILER_BARRIER();
242253
}
243254

244-
// Phase 2: Pop last 3 blocks (Depth 8 -> 5)
245-
for (int j = 7; j >= 5; j--) {
255+
// Phase 2: Pop last blocks (Depth DEPTH_P1 -> DEPTH_P2)
256+
for (int j = DEPTH_P1 - 1; j >= DEPTH_P2; j--) {
246257
trebi_stack.Free(ptrs[j]);
247258
COMPILER_BARRIER();
248259
ptrs[j] = nullptr;
249260
}
250261

251-
// Phase 3: Allocate 10 more blocks (Depth 5 -> 15)
252-
for (int j = 5; j < 15; j++) {
262+
// Phase 3: Allocate more blocks (Depth DEPTH_P2 -> DEPTH_MAX)
263+
for (int j = DEPTH_P2; j < DEPTH_MAX; j++) {
253264
size_t sz = 32 + (rand_pool[r_idx++ & (RAND_POOL_SIZE - 1)] % 128);
254265
ptrs[j] = trebi_stack.Allocate(sz, 8);
255266
if (!ptrs[j]) {
@@ -260,8 +271,8 @@ int main() {
260271
COMPILER_BARRIER();
261272
}
262273

263-
// Phase 4: Free all remaining blocks (Depth 15 -> 0)
264-
for (int j = 14; j >= 0; j--) {
274+
// Phase 4: Free all remaining blocks (Depth DEPTH_MAX -> 0)
275+
for (int j = DEPTH_MAX - 1; j >= 0; j--) {
265276
trebi_stack.Free(ptrs[j]);
266277
COMPILER_BARRIER();
267278
ptrs[j] = nullptr;
@@ -277,10 +288,10 @@ int main() {
277288
double best_wb = get_min_time(times_wb);
278289
double best_trebi = get_min_time(times_trebi);
279290

280-
// Calculation of allocator throughput (each iteration does exactly 36 allocator calls)
281-
const double total_ops_per_round = (double)pattern_iterations * 36.0;
291+
// Calculation of allocator throughput dynamically scaled based on compiled depth
292+
const double total_ops_per_round = (double)pattern_iterations * OPS_PER_ITERATION;
282293

283-
std::cout << "=== Results (Best of " << ROUNDS << " runs, " << pattern_iterations << " iterations/run) ===\n";
294+
std::cout << "=== Results (Best of " << ROUNDS << " runs, " << pattern_iterations << " iterations/run, depth " << DEPTH_MAX << ") ===\n";
284295
std::printf("EasyStack: %.4f sec (%.2f million ops/sec)\n",
285296
best_easy, total_ops_per_round / best_easy / 1e6);
286297

0 commit comments

Comments
 (0)