Skip to content

Instantly share code, notes, and snippets.

@phpclub
Forked from andypost/php85-allocator-bench.md
Created May 27, 2026 14:37
Show Gist options
  • Select an option

  • Save phpclub/e3c0d94f1ed3a7dc67cc90d4785243f4 to your computer and use it in GitHub Desktop.

Select an option

Save phpclub/e3c0d94f1ed3a7dc67cc90d4785243f4 to your computer and use it in GitHub Desktop.
profiling PHP 8.5 with memory allocators on Alpinelinux

Memory allocators on Alpine Linux — PHP & multi-threaded servers

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.


Single-threaded — composer dumpautoload -o --classmap-authoritative

5 runs, median wall + median maxRSS.

ZendMM ON (default PHP runtime — Zend's slab pool services small allocs)

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.

ZendMM OFF (USE_ZEND_ALLOC=0 — every malloc goes through libc)

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.


Multi-threaded — pthread malloc stress

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.


Verdict

Speed tier — recommended for multi-threaded servers

  1. snmalloc — top performer, lowest RSS overhead (+3 %).
  2. jemalloc — battle-tested, strong second.
  3. tcmalloc — very fast, slightly higher RSS.
  4. mimalloc3 insecure — close behind, well-tuned.

Security tier — accept perf cost for stronger guarantees

  1. scudo — LLVM allocator with hardening; cheap multi-thread (47×) but expensive single-thread (+73 % over musl in libc-stress).
  2. hardened-malloc light — same allocator with reduced security; faster than the full variant, decent multi-thread (25×).
  3. hardened-malloc full — strong hardening; quarantine + canaries; 4× under contention, +57 % single-thread overhead.
  4. starmalloc — F*-formally-verified; matches hardened-malloc-class hardening; +22 % single-thread overhead, 4.8× multi-thread.

When to use which on Alpine

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

Aports state

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

snmalloc aport notes

Two musl accommodations needed:

  1. -DSNMALLOC_CLEANUP=PTHREAD_DESTRUCTORS — musl lacks __cxa_thread_atexit_impl that the default CXX11_DESTRUCTORS mode relies on.
  2. Patch src/test/func/memory/memory.cc to gate TEST_LIMITED on __GLIBC__ — musl doesn't typedef rlim64_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.

StarMalloc aport notes

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.


Reproduction

# 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-authoritative

The 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:

php85 — modernization, build speedup, allocator findings

Working notes from 2026-05-05 → 2026-05-07. Container 1b414cf4f971 (skilldlabs/aports-build:edge), 8 cores, 14 GB, x86_64.

1. Default-PHP flip on php85-default branch (committed)

7 commits ahead of alpine:

community/php85: make default PHP version       (provider_priority 80→100, _default_php=yes)
community/php84: yield default to php85         (priority 100→90, _default_php=no)
community/composer: switch to php85
community/phpunit: switch to php85
community/mongo-php-library: switch to php85
community/postfixadmin: switch to php85         (also: composer require otphp:^11, runtime deps tightened, real check())
community/manticore: switch to php85

No unversioned depends="php" consumers existed in aports, so the flip itself breaks zero in-tree packages.

testing/freshrss already migrated on the separate freshrss-php85 branch.

2. postfixadmin — what we actually fixed

Pre-4.0 (3.x) shipped its dependencies bundled in lib/. 4.0.0 (Oct 2024) moved to composer-managed vendor/. Aports' APKBUILD never updated, so since the 4.0.1 upgrade the apk shipped without vendor/ and was effectively broken on install. New APKBUILD:

  • prepare() runs composer require spomky-labs/otphp:^11.0 — bumps the constraint inline (matches upstream's #989 fix, not yet released) AND installs all deps including dev.
  • check() runs composer run-script test (real phpunit suite, 71 tests, 963 assertions). Skipped on x86/armhf/armv7 only the one PHP_INT_MAX-overflow quota test (upstream wontfix).
  • package() re-runs composer install -o --no-dev --no-interaction --no-progress to slim vendor/, then cp -r * → /usr/share/webapps/postfixadmin/.
  • Runtime depends= tightened from just $_php to the actual extension set the app uses (php85-mbstring, gd, mysqli, dom, etc.).

Result: 71.9 MB apk (was 3.2 MB) but actually functional out of the box.

3. php85 build speedup — measured

Current APKBUILD calls _build() twice (workaround for bug 52419 from 2010 — apache2 SAPI can't share a make invocation with CLI/FPM/embed). Both passes build all 50+ shared extensions; pass-2 then rm -f modules/* and rebuilds them. Pure waste.

Measurements on this container, 8 cores:

variant wall user binary equivalence
baseline (current APKBUILD) 758.77 s 3 986 s
pass-1 trimmed (raw) 640.47 s −15.6 % 3 241 s mod_php85.so +14 MB ❌ (statically links default-on extensions)
pass-1 trimmed + --disable-all (no distclean) 600.73 s −20.8 % 3 032 s filter missing from CLI (autoconf cache poisoned)
modernized (--disable-all + --enable-filter + make distclean) 713.33 s −5.9 % 3 235 s CLI byte-identical, all 52 modules sha256-identical

make distclean between passes costs ~110 s (pass-2 has to recompile core/Zend/main from scratch) but is the only safe defense against autoconf cache contamination. Without it, any default-on extension that --disable-all silently flips off in pass-1 stays disabled in pass-2.

Single-pass attempt — failed

Bug 52419 is still real in PHP 8.5: configure rejects multiple SAPIs with "*** ATTENTION *** You've configured multiple SAPIs to be built. You can build only one SAPI module plus CGI, CLI and FPM binaries at the same time." Two passes mandatory. Worth re-testing on each PHP minor.

Modernized APKBUILD shape

  • _shared_extensions — top-level shell var holding all 50+ --enable-foo=shared flags. One source of truth.
  • _build() — single configure+make wrapper. Common flags + caller's "$@" first, then --enable-filter last (always wins over a caller's --disable-all).
  • build()_build --disable-all --disable-phpdbg --disable-cli --with-apxs2 for pass-1, stash libs/libphp.so to $srcdir, make distclean, _build $_shared_extensions --enable-phpdbg --enable-fpm --enable-embed … for pass-2, then restore the apache2 module to sapi/apache2handler/.

Net: 162 → 154 lines, saves ~45 s per build, byte-equivalent output.

Parallelism bottleneck

With 8 cores: user/wall = 4.27, so we're using ~half the cores. Cause: two massive bundled data tables.

20.5 MB  ext/fileinfo/data_file.c        (libmagic database)
10.4 MB  ext/lexbor/lexbor/encoding/multi.c   (HTML5 encoding tables)

Each is a single clang job that runs ~1–2 minutes while 7 other cores idle. No flag-level fix. Would need Makefile patching to compile those specific files with -O0, or upstream PHP splitting them into multiple TUs.

Don't drop --enable-zend-test=shared

Tempting because package() deletes the resulting zend_test.so — but it's needed by check() (PHP's own make test exercises zend_test internals). The pre-package rm is the right pattern: keep correctness signal, exclude from end-user apk.

4. Allocator benchmark — measured

Drupal 11.x-dev, composer dumpautoload -o --classmap-authoritative (7 455 vendor/.php files, 8 499 classes).

With ZendMM on (default)

Allocator Wall median RSS median Δ wall
musl mallocng 2.45 s 61.7 MB
mimalloc2 2.2.7 2.44 s 63.1 MB −0.4 %
jemalloc 5.3.0 2.42 s 63.5 MB −1.2 %
tcmalloc 2.17 2.50 s 67.3 MB +2.0 %

All four within ±2 % wall. ZendMM (PHP's internal slab pool) serves >99 % of small allocations from its own arenas; libc only sees a few thousand chunk grabs per request, so the allocator quality is invisible.

With USE_ZEND_ALLOC=0 (every alloc through libc)

Allocator Wall median Δ wall vs musl
musl 3.20 s
mimalloc2 3.31 s +3.4 %
jemalloc 2.68 s −16.3 %
tcmalloc 2.76 s −13.8 %

Allocator differences only surface when ZendMM is bypassed. jemalloc with ZendMM-off (2.68 s) is only ~10 % slower than ZendMM-on baseline (2.42 s) — striking testimony to how good jemalloc is.

Verdict

Don't bake LD_PRELOAD into Alpine's php85 packaging or php-fpm service files. Buys nothing on standard workloads, costs +2–9 % RSS.

Recommend LD_PRELOAD=/usr/lib/libjemalloc.so.2 only for:

  1. Embedding PHP in a C/C++ host whose allocations bypass ZendMM.
  2. Long-running fragmenting PHP-FPM workers (rare, measure first).
  3. USE_ZEND_ALLOC=0 debug runs (cuts the slowdown roughly in half).

Update — mimalloc 3.3.2 + snmalloc 0.7.4 + StarMalloc + scudo + hardened-malloc

Five allocator candidates measured side-by-side on Alpine. State of each in aports:

Allocator aports state Notes
mimalloc2 community/mimalloc2 shipped (slot 2.x)
mimalloc3 3.3.2 testing/mimalloc3 (merged 2026-05-08) upstream MR 101424 + version bump
snmalloc 0.7.4 drafted aport testing/snmalloc (this session) 2 musl accommodations needed
StarMalloc (git 2867e5e5f2a0) drafted aport testing/starmalloc (this session) F*-verified; light build only
jemalloc 5.3.0 community/jemalloc shipped
tcmalloc 2.17 community/gperftools/tcmalloc shipped
scudo 22.1.3 main/scudo-malloc shipped (LLVM)
hardened-malloc 14 testing/hardened-malloc shipped, full + light variants

snmalloc musl accommodations:

  1. -DSNMALLOC_CLEANUP=PTHREAD_DESTRUCTORS — musl lacks __cxa_thread_atexit_impl that the default CXX11_DESTRUCTORS mode relies on.
  2. Patch src/test/func/memory/memory.cc to gate TEST_LIMITED on __GLIBC__ — musl's <sys/resource.h> doesn't typedef rlim64_t.

snmalloc's full ctest suite: 70/76 passing; 6 failures are all multi-threaded thread-shutdown tests (func-multi_atexit, multi_setspecific, multi_threadatexit × -fast/-check) that segfault under musl + PTHREAD_DESTRUCTORS. Allocator itself is fine; only thread-exit bookkeeping misbehaves. Excluded via ctest -E regex.

StarMalloc aport — uses the upstream "light" build (STEEL_HOME=1 KRML_HOME=1 NODEPEND=1 VENDOR=1 make light) which compiles pre-extracted C files from dist/ + vendored vendor/ headers. No F*/Steel/KaRaMeL toolchain required. Build takes ~4 s. Single 38 KB .so shipped. Tests need the F* toolchain — set options="!check" in the aport.

Single-threaded (composer dumpautoload), ZendMM ON (default PHP runtime):

Allocator Wall RSS vs musl
musl 2.45 s 62 MB
mimalloc2 2.44 s 63 MB −0.4 %
mimalloc3 secure 2.33 s 79 MB −4.9 %
mimalloc3 insecure 2.32 s 69 MB −5.3 %
snmalloc 2.30 s 64 MB −6.1 %
jemalloc 2.42 s 63 MB −1.2 %
tcmalloc 2.50 s 67 MB +2.0 %
scudo 2.44 s 62 MB −0.4 %
hardened-malloc-light 2.52 s 64 MB +2.9 %
hardened-malloc full 2.55 s 67 MB +4.1 %
starmalloc 2.49 s 67 MB +1.6 %

All within ±6 % — ZendMM dominates, libc allocator quality barely matters.

Single-threaded (composer dumpautoload), ZendMM OFF (every alloc through libc):

Allocator Wall RSS vs musl
musl 3.20 s 59 MB
mimalloc2 3.31 s 72 MB +3.4 %
mimalloc3 secure 3.01 s 111 MB −5.9 %
mimalloc3 insecure 2.49 s 91 MB −22 %
snmalloc 2.40 s 67 MB −25 % (best)
jemalloc 2.68 s 64 MB −16 %
tcmalloc 2.76 s 71 MB −14 %
hardened-malloc-light 3.01 s 63 MB −5.9 %
starmalloc 3.92 s 148 MB +22 %
hardened-malloc full 5.02 s 83 MB +57 %
scudo 5.55 s 83 MB +73 %

Allocator quality matters once ZendMM is bypassed. Security allocators (scudo, hardened-malloc, starmalloc) regress because their per-allocation hardening (canaries, quarantine, randomization) costs more than musl's single-thread fast path.

Multi-threaded malloc stress (8 threads × 2M small mallocs each, 10 allocators):

Allocator Wall median vs musl Notes
musl 7.96 s global lock + mmap fallback
hardened-malloc (full) 1.87 s 4.2× strong hardening; quarantine + canaries
starmalloc 1.66 s 4.8× F*-verified; hardened-malloc-class hardening
hardened-malloc-light 0.32 s 25× same allocator, reduced security
mimalloc3 insecure 0.21 s 38×
scudo 0.17 s 47× LLVM allocator with hardening; in main/
tcmalloc 0.07 s 115×
snmalloc-checks 0.04 s 198×
jemalloc 0.04 s 198×
snmalloc 0.03 s 264× (best) message-passing; lowest RSS overhead

The musl baseline's sys=27 s confirms the FrankenPHP diagnosis: musl serializes concurrent mallocs through one global mutex and falls back to mmap/munmap syscalls under heavy contention. Per-thread allocators (mimalloc, jemalloc, tcmalloc) all bypass this entirely.

Recommendations

  1. php85 (FPM/CLI), php83/php84: still no allocator change in packaging. Single-threaded request handling, ZendMM dominates.
  2. NGINX Unit / FreeUnit: the router process is multi-threaded (configurable threads). Recommend LD_PRELOAD=/usr/lib/libsnmallocshim.so for production (best multi-thread numbers; tied with jemalloc and slightly ahead). libmimalloc.so.3 is also valid.
  3. FrankenPHP: same recommendation, even more impact (every thread handles requests). Upstream issue #2302 documents this; they kept mimalloc opt-in via MIMALLOC=1 for static builds. For Alpine packaging, default to LD_PRELOAD=/usr/lib/libsnmallocshim.so in the systemd unit / docker entrypoint.
  4. Long-running multi-threaded workers in general: snmalloc, mimalloc3, or jemalloc — all clear wins on Alpine. Pick by RSS/feature trade-off: snmalloc has the lowest RSS overhead (+3 %) of the three.

Caveats

  • The synthetic multi-threaded test maximizes allocator contention; real Unit/FrankenPHP workloads spend most time in HTTP/PHP code, not in malloc. Real-world wins likely 2–10 %, not 40×. Always measure your specific workload before promoting an allocator to default.
  • mimalloc-secure adds guard pages and double-free detection (~30–50 % slower than insecure on the multi-thread test). For most production use, mimalloc-insecure (release mode without MI_SECURE) is the right pick.

5. Open follow-ups for the upcoming Alpine release

  • Land the modernized APKBUILD as a follow-up commit on php85-default (saves ~45 s per build).
  • Apply the same _shared_extensions + _build refactor to community/php83, community/php84, testing/php86. Identical structure works.
  • File upstream tickets:
    • PHP bug 52419 retest — ask if "one SAPI module + CGI/CLI/FPM" can be relaxed in 8.6/8.7 (would unlock single-pass build, ~50 % speedup).
    • Split ext/fileinfo/data_file.c and ext/lexbor/lexbor/encoding/multi.c into multiple TUs (would unlock parallelism).
  • File postfixadmin upstream PR backporting otphp ^11 to a 4.0.x point release so the aports patch can retire.

6. Reproduction

In container 1b414cf4f971:

# Build measurements
cd /mnt/community/php85
abuild clean fetch verify unpack prepare
/usr/bin/time abuild build

# Allocator measurements
cd /tmp/php85-mem/drupal
LD_PRELOAD=/usr/lib/libjemalloc.so.2 \
    /usr/bin/time -f "%e %M" \
    composer dumpautoload -o --classmap-authoritative

Saved artifacts:

  • /tmp/php85-bench/{baseline-bin,varianta2-bin,modernized3-bin,logs/} — build outputs
  • /tmp/php85-mem/drupal/ — Drupal 11.x-dev install
  • /tmp/php85-mem/results/{musl,mimalloc2,jemalloc,tcmalloc}{,-nopool,-stress}.txt — raw allocator runs
  • /tmp/php85-build-speedup.md — full build report
  • /tmp/php85-allocator-bench.md — full allocator report
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment