jp_alloc is a compact C11 allocator built around per-thread freelists,
on-demand buddy splitting, and optional intermediate size classes. Its hot
allocation and free paths use no locks or atomic operations.
It is optimized for workloads with short-lived, repeatedly reused objects, especially when allocation and deallocation happen on the same thread. It is not intended to match the fragmentation control of a mature general-purpose allocator on every workload.
Each thread has one singly linked freelist per size class:
allocate: pop tls.freelist[pool]
free: push tls.freelist[pool]
Both operations are plain thread-local pointer accesses. A block freed by a different thread moves to the freeing thread's pool. This keeps the hot path simple, but sustained cross-thread memory flow can strand reusable blocks in one thread while another thread maps new memory.
When a thread exits, its freelist pointers are dropped. The mapped regions remain owned by the process and are reclaimed by the operating system at process exit.
The base classes are powers of two from 1 byte through 8 MiB. Restricted K=4 intermediate classes are enabled by default:
..., 64, 128, 160, 256, 320, 512, 640, 1024, 1280, 2048, 4096, 8192, ...
Power-of-two classes use binary buddy splitting. Intermediate classes use an asymmetric split:
1 * small + 3 * intermediate = power-of-two parent
For example:
4096 = 256 + 3 * 1280
Intermediate splits are limited to parents no larger than 4 KiB, so every
asymmetric split remains within one page. Define
JP_ALLOC_INTERMEDIATE_K=0 to use only power-of-two classes. K=6 is also
available experimentally.
Pool selection is constant time: one count-leading-zeros operation, one intermediate threshold comparison where needed, and fixed index arithmetic.
An empty pool recursively splits a larger block. The largest class obtains a new 8 MiB anonymous mapping. Demand paging means untouched parts of that mapping do not consume physical memory.
There is no buddy coalescing. Once split, blocks remain in their current size classes.
Normal malloc-style allocation reserves a hidden header containing the pool
ID. When the size is known again at deallocation, the sized API exposes the
complete raw block:
void *p = jp_alloc_sized(128);
jp_free_sized(p, 128);Fixed-type users can avoid repeating numeric sizes:
static const struct jp_pool_config node_pool =
JP_POOL_CONFIG(struct node);
struct node *node = jp_pool_alloc(&node_pool);
jp_pool_free(&node_pool, node);Sized and unsized allocations share the same underlying freelists. Once a block is free, either API can reuse it. The allocation and deallocation APIs must not be mixed for a live pointer:
void *a = jp_alloc_sized(80);
jp_free(a); /* invalid */
void *b = jp_alloc(80);
jp_free_sized(b, 80); /* invalid */Release builds trust the size supplied to jp_free_sized. Debug builds store
an address/next/pool-derived cookie while a block is free and validate it
before following the freelist link. This detects corrupted free metadata,
many writes after free, and immediate duplicate frees. The first wrong-size
free cannot be detected without retaining metadata while the block is live.
Optional page advice applies only to complete payload pages of pooled blocks at least 64 KiB. The metadata page remains resident, and advice completes before the block is published on a freelist. This avoids races with reuse and preserves in-band freelist links.
Three modes are available:
JP_ALLOC_MADVISE_NONE
JP_ALLOC_MADVISE_DONTNEED
JP_ALLOC_MADVISE_FREENo advice is the default. Calling madvise on every free adds enough syscall
overhead to hurt the tested applications. MADV_DONTNEED also forces payload
pages to fault back in on reuse. Linux MADV_FREE avoids repeated faults when
memory is plentiful, but still pays one syscall per free and does not lower
RSS until the kernel reclaims the lazy-free pages.
A future hot/cold freelist could apply MADV_FREE only to blocks that survive
an aging generation, amortizing advice over cold blocks.
#include "jp_alloc.h"
void *jp_alloc(size_t size);
void jp_free(void *ptr);
void *jp_calloc(size_t count, size_t size);
void *jp_realloc(void *ptr, size_t new_size);
void *jp_alloc_aligned(size_t alignment, size_t size);
size_t jp_good_size(size_t size);
void *jp_alloc_sized(size_t size);
void jp_free_sized(void *ptr, size_t size);
void *jp_realloc_sized(void *ptr, size_t old_size, size_t new_size);
void *jp_pool_alloc(const struct jp_pool_config *pool);
void jp_pool_free(const struct jp_pool_config *pool, void *ptr);Linking jp_alloc.c also overrides the process malloc, free, calloc,
and realloc symbols. jp_alloc_cpp.cpp provides C++ global new/delete
overrides.
make # jp_alloc.so and debug jp_allocd.so
make test # K=0, debug K=0, K=4, and MADV_FREE variants
make bench # synthetic workload binaries
make madvise-bench # NONE/DONTNEED/FREE focused binariesPreload example:
LD_PRELOAD=./jp_alloc.so your_programFocused advice examples:
./jp_madvise_none reuse 262144 512 30
./jp_madvise_dontneed idle 262144 512
./jp_madvise_free pressure 262144 512Measurements below were collected on an AMD Ryzen Threadripper PRO 7955WX
(16 cores, 32 hardware threads), Linux 6.17, using -O2. Synthetic results
are one 5-second timed run per allocator. Real-application results are medians
of three runs unless noted.
This repeatedly allocates and frees tup-shaped fixed-size objects. It strongly favors thread-local LIFO reuse.
| Threads | Allocator | Throughput | Approx. p99 | Peak RSS |
|---|---|---|---|---|
| 1 | jp_alloc | 14.8 Mops/s | 96 ns | 2.6 MiB |
| 1 | tcmalloc | 14.7 Mops/s | 96 ns | 8.4 MiB |
| 1 | jemalloc | 11.8 Mops/s | 96 ns | 5.0 MiB |
| 1 | mimalloc | 11.5 Mops/s | 192 ns | 2.9 MiB |
| 1 | glibc | 8.7 Mops/s | 192 ns | 2.4 MiB |
| 8 | jp_alloc | 112.1 Mops/s | 96 ns | 4.3 MiB |
| 8 | mimalloc | 89.2 Mops/s | 192 ns | 6.4 MiB |
| 8 | jemalloc | 87.6 Mops/s | 192 ns | 10.4 MiB |
| 8 | tcmalloc | 68.7 Mops/s | 192 ns | 12.5 MiB |
| 8 | glibc | 63.1 Mops/s | 192 ns | 3.4 MiB |
| 300 | jp_alloc | 272.1 Mops/s | 96 ns | 77.8 MiB |
| 300 | mimalloc | 242.8 Mops/s | 192 ns | 153.4 MiB |
| 300 | jemalloc | 227.5 Mops/s | 192 ns | 215.0 MiB |
| 300 | glibc | 145.4 Mops/s | 192 ns | 49.6 MiB |
| 300 | tcmalloc | 38.8 Mops/s | 98 us | 162.5 MiB |
This keeps 4096 objects outstanding per thread before draining them.
| Threads | Allocator | Throughput | Approx. p99 | Peak RSS |
|---|---|---|---|---|
| 1 | jp_alloc | 34.9 Mops/s | 24 ns | 3.1 MiB |
| 1 | tcmalloc | 34.2 Mops/s | 24 ns | 8.8 MiB |
| 1 | mimalloc | 31.4 Mops/s | 48 ns | 3.4 MiB |
| 1 | jemalloc | 22.9 Mops/s | 48 ns | 5.3 MiB |
| 1 | glibc | 18.7 Mops/s | 48 ns | 2.8 MiB |
| 8 | jp_alloc | 257.7 Mops/s | 24 ns | 8.3 MiB |
| 8 | mimalloc | 231.7 Mops/s | 48 ns | 9.9 MiB |
| 8 | tcmalloc | 202.7 Mops/s | 96 ns | 15.1 MiB |
| 8 | jemalloc | 168.7 Mops/s | 96 ns | 13.2 MiB |
| 8 | glibc | 136.5 Mops/s | 96 ns | 5.6 MiB |
| 300 | jp_alloc | 803.1 Mops/s | 48 ns | 227.1 MiB |
| 300 | mimalloc | 733.1 Mops/s | 96 ns | 279.4 MiB |
| 300 | jemalloc | 502.8 Mops/s | 96 ns | 308.1 MiB |
| 300 | glibc | 378.9 Mops/s | 192 ns | 152.1 MiB |
| 300 | tcmalloc | 22.6 Mops/s | 197 us | 213.2 MiB |
These results demonstrate the intended fast path, not universal allocator superiority. The benchmark has predictable same-thread reuse and does not model arbitrary object-size distributions or long-running fragmentation.
The same current K=4 allocator was compared through LD_PRELOAD:
| Workload | jp_alloc | glibc | Best measured |
|---|---|---|---|
| GCC compiling SQLite | 13.14s / 371MiB | 12.59s / 362MiB | tcmalloc: 12.45s |
| ffmpeg 1080p transcode | 1.30s / 2162MiB | 1.25s / 2033MiB | jemalloc: 1.24s |
| OpenSCAD 8K spheres | 0.36s / 149MiB | 0.31s / 124MiB | jemalloc: 0.30s |
| Inkscape 10K paths | 0.60s / 230MiB | 0.54s / 184MiB | mimalloc: 0.52s |
| SQLite 2M rows | 4.91s / 2764MiB | 4.37s / 1733MiB | glibc |
| Blender 1000 spheres | 42.49s / 24.2GiB | 14.03s / 464MiB | mimalloc: 13.16s |
The Blender result is pathological: Python-heavy 20-60-byte allocations are not helped by the current intermediate classes, and the lack of coalescing greatly amplifies retained memory. General-purpose allocators remain the right choice for heterogeneous applications.
In the tup-jp integration, known fixed-size parser objects use the sized
descriptor API. A clean parse of bes_devel with three variants measured:
| Build | Time | Peak RSS |
|---|---|---|
| Unsized fixed objects | 61.08s | 497,080 KiB |
| Sized descriptors | 60.90s | 437,132 KiB |
The sized API reduced peak RSS by about 60 MiB (12%) with no material runtime change in alternating runs.
At 256 KiB, 512 blocks, and 30 immediate-reuse cycles:
| Mode | Time | Final RSS | LazyFree |
Minor faults |
|---|---|---|---|---|
| NONE | 0.049s | 130MiB | 0 | 32,771 |
MADV_FREE |
0.139s | 130MiB | 126MiB | 32,771 |
MADV_DONTNEED |
1.530s | 3.7MiB | 0 | 968,195 |
Clean tup parse medians were 57.78s for NONE, 58.49s for
MADV_DONTNEED, and 58.67s for MADV_FREE, with equivalent peak RSS.
Consequently, no advice is the default.
- No coalescing after blocks are split.
- No global or owner-directed return path for cross-thread frees.
- Exiting threads abandon their freelist reachability until process exit.
- Sparse classes below 128 bytes can waste substantial memory.
- Sized free trusts the caller-provided size in release builds.
MADV_FREEis Linux-specific; unsupported platforms compile that mode as no advice.
| Flag | Default | Description |
|---|---|---|
JP_ALLOC_DEBUG |
off | Freelist cookie and double-free checks |
JP_ALLOC_INTERMEDIATE_K |
4 | Intermediate classes; valid values are 0, 4, and 6 |
JP_ALLOC_MADVISE_SIZE |
65536 | Minimum pooled block size eligible for payload advice |
JP_ALLOC_MADVISE_MODE |
JP_ALLOC_MADVISE_NONE |
NONE, DONTNEED, or Linux FREE |
JP_ALLOC_STATS |
off | Print per-pool statistics at exit |
JP_CACHELINE |
64 | Alignment override for target platforms |
- Linux x86-64 is the primary tested platform.
- POSIX builds require pthreads and anonymous
mmap. - Windows uses
VirtualAlloc;madvisemodes are unavailable. - The source includes GCC/Clang and MSVC compatibility fallbacks, but not all combinations are continuously tested.
GPL-3.0-or-later. See LICENSE.