Arena versus malloc: the 50× claim
Measure your machineNeeds C compiler, Python
Problem
A general-purpose allocator handles each allocation and free individually, finding a slot of the right size and recording what is free. A bump-pointer arena hands out the next bytes of a large block and frees everything at once by resetting the pointer: each allocation is an addition. Arenas fit data with a shared lifetime (a request, a batch, a query). They also avoid fragmentation: a malloc heap that frees a random half of its objects keeps the pages those objects were spread across.
Allocate and free 100 million small objects (24 bytes) two ways: one malloc/free per object, and one bump-pointer arena reset at the end of each batch of 1,000,000 objects. Print the ns per allocation. Then allocate objects of random sizes, free a random half in random order, allocate again, and print the process's resident memory next to the sum of live bytes. Repeat with the arena. Compiled with -O2; resident memory is the process footprint (macOS) or RSS (Linux), and the data is random so that memory compression cannot hide it.
The question. How much faster is the arena per allocation, and how far does resident memory drift from live bytes under each scheme?
Predict
Expect the arena to be one to two orders of magnitude faster per allocation (the range quoted is 50–100×; your ratio depends on the allocator and the optimization level). The fragmentation gap: RSS stays high while live bytes are low; with the arena the gap disappears.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
arena-versus-malloc-the-50-claim.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Arena versus malloc: the 50x claim, and fragmentation. Experiment 1: allocate and free 100 million 24-byte objects with one malloc/free each, then with a bump-pointer arena reset after every batch of 1,000,000. Experiment 2: allocate objects of random sizes, free a random half in random order, allocate again, and print the process's resident memory next to the live bytes; then the same with an arena. The C program is compiled (-O2) and run by this script (needs cc). """ import argparse, json, os, subprocess, tempfile p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--batches", type=int, default=100, help="Experiment 1: batches of 1,000,000 objects.") p.add_argument("--objects", type=int, default=500_000, help="Experiment 2: objects of random size.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() C = r""" #include <stdio.h> #include <stdlib.h> #include <string.h> #include <time.h> #include <sys/mman.h> #ifdef __APPLE__ #include <mach/mach.h> static double rss_mb(void){ task_vm_info_data_t i; mach_msg_type_number_t n = TASK_VM_INFO_COUNT; /* footprint: counts compressed pages too */ task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&i, &n); return i.phys_footprint / 1e6; } #else #include <unistd.h> static double rss_mb(void){ long pages = 0, r = 0; FILE *f = fopen("/proc/self/statm", "r"); fscanf(f, "%ld %ld", &pages, &r); fclose(f); return r * sysconf(_SC_PAGESIZE) / 1e6; } #endif typedef struct { long v; long a; long b; } Item; /* 24 bytes */ typedef struct { char *base; size_t used, cap; } Arena; static void *arena_alloc(Arena *a, size_t n){ n = (n + 15) & ~(size_t)15; if (a->used + n > a->cap) return NULL; void *p = a->base + a->used; a->used += n; return p; } static void arena_reset(Arena *a){ a->used = 0; } static double now(void){ struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec + t.tv_nsec / 1e9; } static unsigned long long s = 88172645463325252ULL; static unsigned long long rnd(void){ s ^= s << 13; s ^= s >> 7; s ^= s << 17; return s; } static void fill(char *p, size_t n){ for (size_t i = 0; i + 8 <= n; i += 8) { unsigned long long r = rnd(); memcpy(p + i, &r, 8); } } /* random bytes: nothing to compress */ int main(int argc, char **argv){ int batches = atoi(argv[1]); long objs = atol(argv[2]); long sink = 0; /* experiment 1 */ static Item *keep[1000000]; double t = now(); for (int b = 0; b < batches; b++) { for (int i = 0; i < 1000000; i++) { Item *x = malloc(sizeof(Item)); x->v = i; keep[i] = x; } for (int i = 0; i < 1000000; i++) { sink += keep[i]->v; free(keep[i]); } } double t_malloc = now() - t; Arena a = { malloc(1000000 * 32), 0, 1000000 * 32 }; t = now(); for (int b = 0; b < batches; b++) { for (int i = 0; i < 1000000; i++) { Item *x = arena_alloc(&a, sizeof(Item)); x->v = i; keep[i] = x; } for (int i = 0; i < 1000000; i++) sink += keep[i]->v; arena_reset(&a); /* frees all 1,000,000 objects at once */ } double t_arena = now() - t; printf("alloc %.6f %.6f %ld\n", t_malloc / (batches * 1e6) * 1e9, t_arena / (batches * 1e6) * 1e9, sink & 1); /* experiment 2: random sizes, free a random half, allocate again */ char **ptr = malloc(objs * sizeof(char *)); size_t *sz = malloc(objs * sizeof(size_t)); double live = 0; double base = rss_mb(); for (long i = 0; i < objs; i++) { sz[i] = 16 + rnd() % 1024; ptr[i] = malloc(sz[i]); fill(ptr[i], sz[i]); live += sz[i]; } double rss_full = rss_mb() - base, live_full = live / 1e6; for (long i = objs - 1; i > 0; i--) { long j = rnd() % (i + 1); char *tp = ptr[i]; ptr[i] = ptr[j]; ptr[j] = tp; size_t ts = sz[i]; sz[i] = sz[j]; sz[j] = ts; } for (long i = 0; i < objs / 2; i++) { free(ptr[i]); live -= sz[i]; ptr[i] = NULL; } double rss_half = rss_mb() - base, live_half = live / 1e6; for (long i = 0; i < objs / 4; i++) { sz[i] = 2048 + rnd() % 2048; ptr[i] = malloc(sz[i]); fill(ptr[i], sz[i]); live += sz[i]; } /* larger than the holes */ double rss_again = rss_mb() - base, live_again = live / 1e6; double after_free = 0; (void)after_free; printf("frag malloc %.1f %.1f %.1f %.1f %.1f %.1f\n", live_full, rss_full, live_half, rss_half, live_again, rss_again); for (long i = 0; i < objs; i++) free(ptr[i]); /* the same lifetimes with arenas: one arena per generation, dropped whole when its generation dies */ base = rss_mb(); size_t cap = (size_t)objs * 1100; Arena g1 = { mmap(NULL, cap, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0), 0, cap }; live = 0; /* arenas take their chunks from the OS */ for (long i = 0; i < objs; i++) { size_t n = 16 + rnd() % 1024; char *q = arena_alloc(&g1, n); fill(q, n); sink += q[n - 1]; live += n; } rss_full = rss_mb() - base; live_full = live / 1e6; munmap(g1.base, cap); live_half = live_full / 2; live = 0; /* a generation dies together: the arena is released */ Arena g2 = { mmap(NULL, cap / 2, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0), 0, cap / 2 }; for (long i = 0; i < objs / 2; i++) { size_t n = 16 + rnd() % 1024; char *q = arena_alloc(&g2, n); fill(q, n); sink += q[n - 1]; live += n; } rss_half = rss_mb() - base; live_half = live / 1e6; printf("frag arena %.1f %.1f %.1f %.1f\n", live_full, rss_full, live_half, rss_half); fprintf(stderr, "%ld\n", sink & 1); /* read the bytes, so the writes cannot be dropped */ return 0; } """ d = tempfile.mkdtemp(); src = os.path.join(d, "arena.c"); exe = os.path.join(d, "arena"); open(src, "w").write(C) subprocess.run(["cc", "-O2", src, "-o", exe], check=True) out = subprocess.run([exe, str(args.batches), str(args.objects)], capture_output=True, text=True, check=True).stdout.splitlines() a = out[0].split(); fm = list(map(float, out[1].split()[2:])); fa = list(map(float, out[2].split()[2:])) alloc = [{"allocator": "malloc/free per object", "ns_per_alloc": float(a[1])}, {"allocator": "bump arena, reset per batch", "ns_per_alloc": float(a[2])}] frag = [{"phase": "malloc: all allocated", "live_mb": fm[0], "rss_mb": fm[1]}, {"phase": "malloc: random half freed", "live_mb": fm[2], "rss_mb": fm[3]}, {"phase": "malloc: larger objects allocated again", "live_mb": fm[4], "rss_mb": fm[5]}, {"phase": "arena: all allocated", "live_mb": fa[0], "rss_mb": fa[1]}, {"phase": "arena: generation dropped, half reallocated", "live_mb": fa[2], "rss_mb": fa[3]}] for r in frag: r["gap_mb"] = r["rss_mb"] - r["live_mb"] summary = {"ratio": alloc[0]["ns_per_alloc"] / alloc[1]["ns_per_alloc"]} for r in alloc: print(f"{r['allocator']:<28} {r['ns_per_alloc']:.2f} ns per allocation") print(f"arena / malloc speed-up: {summary['ratio']:.0f}x") for r in frag: print(f"{r['phase']:<44} live {r['live_mb']:7.1f} MB resident {r['rss_mb']:7.1f} MB gap {r['gap_mb']:7.1f} MB") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"alloc": alloc, "fragmentation": frag}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run arena-versus-malloc-the-50-claim.py uv run arena-versus-malloc-the-50-claim.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --batches100 | Experiment 1: batches of 1,000,000 objects. |
| --objects500,000 | Experiment 2: objects of random size. |
Our run
Per allocation
| Allocator | ns per allocation |
|---|---|
| malloc/free per object | 21.9 |
| bump arena, reset per batch | 1.76 |
Resident memory against live bytes
| Phase | Live (MB) | Resident (MB) | Gap (MB) |
|---|---|---|---|
| malloc: all allocated | 264 | 300 | 36.4 |
| malloc: random half freed | 132 | 300 | 168 |
| malloc: larger objects allocated again | 516 | 746 | 230 |
| arena: all allocated | 264 | 268 | 3.9 |
| arena: generation dropped, half reallocated | 132 | 134 | 2 |
malloc/free per object 21.89 ns per allocation
bump arena, reset per batch 1.76 ns per allocation
arena / malloc speed-up: 12x
malloc: all allocated live 264.0 MB resident 300.4 MB gap 36.4 MB
malloc: random half freed live 132.1 MB resident 300.4 MB gap 168.3 MB
malloc: larger objects allocated again live 515.8 MB resident 745.8 MB gap 230.0 MB
arena: all allocated live 263.8 MB resident 267.7 MB gap 3.9 MB
arena: generation dropped, half reallocated live 132.0 MB resident 134.0 MB gap 2.0 MBWhat we found
The arena took 1.76 ns per allocation against 21.9 ns for malloc/free: 12.4× faster, one order of magnitude rather than the quoted 50–100×, because this system's allocator is fast for small fixed sizes. Fragmentation was clear: after freeing a random half, the malloc heap held 132 MB live in 300 MB resident, and allocating larger objects again pushed the gap to 230 MB. With arenas, dropping a generation returned its memory whole; the gap stayed at 2 MB.
Read it
The speed-up depends on the allocator you compare with and is often smaller than quoted; the fragmentation result does not. When objects share a lifetime, an arena makes both allocation and release trivial and keeps memory proportional to what is live. When they do not, it holds memory until the last object of the arena dies.