Measured 2026-05-07 to 2026-05-08 on Alpine edge, x86_64, 8 cores, 14 GB RAM.
PHP 8.5.6, opcache always on. Drupal 11.x-dev (7 455 vendor .php, 8 499 classes)
as the realistic single-threaded workload. A small C harness (8 pthreads ×
2 000 000 small mallocs) for the multi-threaded contention scenario.
11 allocators tested:
| Allocator | Version | Source |
|---|---|---|
| musl mallocng | 1.2.5 | built into Alpine libc |
| mimalloc2 | 2.2.7 | community/mimalloc2 |
| mimalloc3 (secure / insecure) | 3.3.2 | testing/mimalloc3 (merged 2026-05-08) |
| jemalloc | 5.3.0 | community/jemalloc |
| tcmalloc | 2.17 | community/gperftools |
| scudo | 22.1.3 | main/scudo-malloc (LLVM) |
| hardened-malloc full | 14 | testing/hardened-malloc |
| hardened-malloc light | 14 | testing/hardened-malloc (-light variant) |
| snmalloc | 0.7.4 | drafted aport testing/snmalloc |
| StarMalloc | git 2867e5e5 | drafted aport testing/starmalloc |
All measurements via LD_PRELOAD=… against an unmodified php85 / gcc binary.
5 runs, median wall + median maxRSS.
| Allocator | Wall | RSS | Δ wall |
|---|---|---|---|
| musl | 2.45 s | 62 MB | — |
| mimalloc2 | 2.44 | 63 MB | −0.4 % |
| mimalloc3 secure | 2.33 | 79 MB | −4.9 % |
| mimalloc3 insecure | 2.32 | 69 MB | −5.3 % |
| snmalloc | 2.30 | 64 MB | −6.1 % |
| jemalloc | 2.42 | 63 MB | −1.2 % |
| tcmalloc | 2.50 | 67 MB | +2.0 % |
| scudo | 2.44 | 62 MB | −0.4 % |
| hardened-malloc-light | 2.52 | 64 MB | +2.9 % |
| hardened-malloc full | 2.55 | 67 MB | +4.1 % |
| starmalloc | 2.49 | 67 MB | +1.6 % |
All within ±6 %. ZendMM serves >99 % of small allocations from its own slab pool; libc only sees a few thousand chunk grabs across an entire request — too coarse-grained for allocator quality to matter.
| Allocator | Wall | RSS | Δ wall |
|---|---|---|---|
| musl | 3.20 s | 59 MB | — |
| mimalloc2 | 3.31 | 72 MB | +3.4 % (regression) |
| mimalloc3 secure | 3.01 | 111 MB | −5.9 % |
| mimalloc3 insecure | 2.49 | 91 MB | −22 % |
| snmalloc | 2.40 | 67 MB | −25 % (best) |
| jemalloc | 2.68 | 64 MB | −16 % |
| tcmalloc | 2.76 | 71 MB | −14 % |
| hardened-malloc-light | 3.01 | 63 MB | −5.9 % |
| starmalloc | 3.92 | 148 MB | +22 % |
| hardened-malloc full | 5.02 | 83 MB | +57 % |
| scudo | 5.55 | 83 MB | +73 % |
Bypass the slab pool and the spread blows up to ~100 percentage points. Speed-tier: snmalloc / mimalloc3-insecure / jemalloc / tcmalloc all beat musl by 14–25 %. Security-tier (scudo, hardened-malloc full, starmalloc) regress because their per-allocation hardening (canaries, quarantine, randomization) costs more than musl's single-thread fast path.
Notable: snmalloc with USE_ZEND_ALLOC=0 (2.40 s) is faster than the
default ZendMM-on path with musl (2.45 s). snmalloc's fast path is good
enough that PHP's hand-rolled slab pool barely contributes anymore.
8 threads × 2 000 000 small mallocs each (16–1040 byte sizes, touched and freed). Models the contention pattern of NGINX Unit's router thread pool, FrankenPHP's worker threads, or any multi-threaded server doing per-request allocation.
| Allocator | Wall | sys time | vs musl |
|---|---|---|---|
| musl | 7.96 s | 27.7 s | — |
| hardened-malloc full | 1.87 | — | 4.2× |
| starmalloc | 1.66 | — | 4.8× |
| hardened-malloc-light | 0.32 | — | 25× |
| mimalloc2 | 0.40 | 1.5 | 21× |
| mimalloc3 secure | 0.31 | 0.7 | 27× |
| mimalloc3 insecure | 0.21 | 0.8 | 38× |
| scudo | 0.17 | — | 47× |
| tcmalloc | 0.07 | 0.0 | 115× |
| snmalloc-checks | 0.04 | — | 200× |
| jemalloc | 0.04 | 0.0 | 200× |
| snmalloc | 0.03 | 0.0 | 264× (best) |
The musl baseline burns 27.7 s of sys time while the others stay under 1.5 s. That's the FrankenPHP issue's diagnosis exactly: musl serializes concurrent mallocs through one global mutex and falls back to mmap/munmap syscalls under contention. Per-thread arena allocators (mimalloc, jemalloc, tcmalloc, snmalloc) bypass this lock entirely.
Caveat: this is a synthetic stress test. Real workloads spend most time in HTTP/PHP code, not in malloc. Real-world wins from swapping the allocator are typically 2–10 %, not 200×. Always measure your specific workload before promoting an allocator to default.
- snmalloc — top performer, lowest RSS overhead (+3 %).
- jemalloc — battle-tested, strong second.
- tcmalloc — very fast, slightly higher RSS.
- mimalloc3 insecure — close behind, well-tuned.
- scudo — LLVM allocator with hardening; cheap multi-thread (47×) but expensive single-thread (+73 % over musl in libc-stress).
- hardened-malloc light — same allocator with reduced security; faster than the full variant, decent multi-thread (25×).
- hardened-malloc full — strong hardening; quarantine + canaries; 4× under contention, +57 % single-thread overhead.
- starmalloc — F*-formally-verified; matches hardened-malloc-class hardening; +22 % single-thread overhead, 4.8× multi-thread.
| Workload | Recommendation |
|---|---|
| php85-fpm / php85-cli, single-threaded request handlers | musl. ZendMM dominates; LD_PRELOAD buys nothing. |
| NGINX Unit (multi-threaded router) | LD_PRELOAD snmalloc or jemalloc. |
| FrankenPHP | LD_PRELOAD snmalloc (default in service file / Docker entrypoint). |
| Embedding PHP in a multi-threaded C host | LD_PRELOAD snmalloc or jemalloc. |
| Untrusted-input PHP/server | LD_PRELOAD scudo or hardened-malloc-light. |
| Highest assurance (formally verified) | LD_PRELOAD starmalloc. |
Debugging with USE_ZEND_ALLOC=0 |
LD_PRELOAD snmalloc (matches ZendMM-on baseline). |
| Aport | State |
|---|---|
community/mimalloc2 2.2.7 |
shipped |
testing/mimalloc3 3.3.2 |
merged 2026-05-08 (this session bumped 3.3.1 → 3.3.2) |
community/jemalloc 5.3.0 |
shipped |
community/gperftools (tcmalloc subpkg) 2.17 |
shipped |
main/scudo-malloc 22.1.3 |
shipped |
testing/hardened-malloc 14 |
shipped |
testing/snmalloc 0.7.4 |
drafted this session |
testing/starmalloc git 2867e5e5 |
drafted this session |
Two musl accommodations needed:
-DSNMALLOC_CLEANUP=PTHREAD_DESTRUCTORS— musl lacks__cxa_thread_atexit_implthat the defaultCXX11_DESTRUCTORSmode relies on.- Patch
src/test/func/memory/memory.ccto gateTEST_LIMITEDon__GLIBC__— musl doesn't typedefrlim64_t.
ctest: 70 of 76 passing. The 6 failures are multi-threaded thread-shutdown
tests (func-multi_atexit, multi_setspecific, multi_threadatexit) that
segfault under musl + PTHREAD_DESTRUCTORS. Allocator itself is fine; only
thread-exit bookkeeping misbehaves. Excluded via ctest -E.
Uses upstream's "light" build (STEEL_HOME=1 KRML_HOME=1 NODEPEND=1 VENDOR=1 make light) which compiles pre-extracted C from dist/ plus
vendored headers from vendor/. No F*/Steel/KaRaMeL toolchain required.
~4 s build, single 38 KB .so. Test suite needs the full F* toolchain so
options="!check" is set in the aport.
# Install allocators (apk add the shipped ones; build snmalloc + starmalloc from drafted aports)
sudo apk add mimalloc2 jemalloc tcmalloc scudo-malloc hardened-malloc
sudo apk add /home/skilld/packages/testing/x86_64/{mimalloc3,snmalloc,starmalloc}-*.apk
# Single-threaded bench
cd /tmp/php85-mem/drupal
LD_PRELOAD=/usr/lib/libsnmallocshim.so /usr/bin/time -f "%e %M" \
composer dumpautoload -o --classmap-authoritative
# Multi-threaded bench (8 pthreads × 2M mallocs each)
LD_PRELOAD=/usr/lib/libsnmallocshim.so /usr/bin/time -f "%e" \
/tmp/threaded_malloc
# Bypass ZendMM (single-threaded libc stress)
USE_ZEND_ALLOC=0 LD_PRELOAD=/usr/lib/libsnmallocshim.so /usr/bin/time -f "%e %M" \
composer dumpautoload -o --classmap-authoritativeThe threaded malloc harness:
// gcc -O2 -pthread -o /tmp/threaded_malloc threaded_malloc.c
#define _GNU_SOURCE
#include <pthread.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#define ITERS 2000000
static int n_threads = 8;
static volatile size_t sink = 0;
static void *worker(void *arg) {
size_t local = 0;
for (int i = 0; i < ITERS; i++) {
size_t sz = 16 + (i & 0x3FF);
char *p = malloc(sz);
p[0] = (char)i;
p[sz-1] = (char)(i >> 8);
local += (size_t)p ^ p[0];
free(p);
}
sink += local;
return NULL;
}
int main(int argc, char **argv) {
if (argc > 1) n_threads = atoi(argv[1]);
pthread_t *t = calloc(n_threads, sizeof(*t));
for (int i = 0; i < n_threads; i++) pthread_create(&t[i], NULL, worker, NULL);
for (int i = 0; i < n_threads; i++) pthread_join(t[i], NULL);
fprintf(stderr, "sink=%zu\n", sink);
return 0;
}References: