Skip to content
About
Main content

Arena versus malloc: the 50× claim

Measure your machineNeeds C compiler, Python

Problem

A general-purpose handles each allocation and free individually, finding a slot of the right size and recording what is free. A bump-pointer 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 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 (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

How many times faster per allocation is the bump arena than malloc/free?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    arena-versus-malloc-the-50-claim.py112 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat it changes
--batches100 Experiment 1: batches of 1,000,000 objects.
--objects500,000 Experiment 2: objects of random size.

Our run

Per allocation

Per allocation
Allocatorns per allocation
malloc/free per object21.9
bump arena, reset per batch1.76

Resident memory against live bytes

Resident memory against live bytes
PhaseLive (MB)Resident (MB)Gap (MB)
malloc: all allocated26430036.4
malloc: random half freed132300168
malloc: larger objects allocated again516746230
arena: all allocated2642683.9
arena: generation dropped, half reallocated1321342
Reference runApple M2 · 8 cores · 8 GB
Output · uv run arena-versus-malloc-the-50-claim.py
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 MB
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What 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.