@@ -27,6 +27,7 @@ what you use.
2727- [ Build and Install] ( #build-and-install )
2828- [ Feature Flags] ( #feature-flags )
2929- [ Libc-Diet (Freestanding Build)] ( #libc-diet-freestanding-build )
30+ - [ Allocator Performance] ( #allocator-performance )
3031- [ Six Core Ideas] ( #six-core-ideas )
3132 - [ 1. Allocators are user-owned values, never global state] ( #1-allocators-are-user-owned-values-never-global-state )
3233 - [ 2. ` Scope ` is lexical RAII, in plain C] ( #2-scope-is-lexical-raii-in-plain-c )
@@ -372,6 +373,147 @@ A regression in either fails CI independently.
372373
373374---
374375
376+ ## Allocator Performance
377+
378+ How ` HeapAllocator ` (the default backend) compares against four production
379+ general-purpose allocators on a microbenchmark suite. Lower is better
380+ in every row.
381+
382+ ### Setup
383+
384+ - Hardware: Intel Core Ultra 7 165U, 14 logical cores, AVX2.
385+ - Kernel: Linux 6.18.25 (NixOS).
386+ - Toolchain: gcc 15.2.0.
387+ - Allocator versions (nixpkgs unstable, channel ` nixos-unstable ` as of
388+ the date of this README):
389+ - glibc 2.42 system malloc
390+ - jemalloc 5.3.0
391+ - mimalloc 3.3.0 — rebuilt with ` -DMI_STAT=1 ` so committed-bytes
392+ introspection works (default release build leaves the counter
393+ in the no-update branch)
394+ - gperftools 2.17.2 (tcmalloc)
395+ - MisraStdC at the commit of this README. Two variants:
396+ ` heap_validate_full=true ` (the default, "misra-full" in the
397+ table) and ` heap_validate_full=false ` ("misra-fast").
398+
399+ - Build configuration: every allocator at ` -O3 -march=native ` , LTO
400+ off, sanitisers off. Matches what nixpkgs ships its own
401+ jemalloc/mimalloc/tcmalloc with, so no allocator has a codegen
402+ advantage from the build. MisraStdC built with all upstream
403+ feature flags at their default values plus
404+ ` -Dheap_validate_full={true,false} ` .
405+
406+ - Harness: Google Benchmark 1.9.4, 3 repetitions per row, median
407+ reported. One binary per allocator; each binary links exactly
408+ one general-purpose allocator (via the linker's normal ` malloc `
409+ resolution), with MisraStdC in its own binary that uses the
410+ typed ` HeapAllocator ` API directly.
411+
412+ - Memory operations: harness-side ` memset ` calls go through libc's
413+ vectorised version for every backend (the in-tree mem* overrides
414+ from ` Source/Misra/_Freestanding.c ` are skipped at the bench
415+ build level so they don't interpose on the harness's own
416+ touch loops -- otherwise the bench would partly measure memset
417+ throughput, not the allocator).
418+
419+ ### Timing
420+
421+ Median nanoseconds (or microseconds) per loop iteration:
422+
423+ | Benchmark | glibc | jemalloc | mimalloc | tcmalloc | misra-fast | misra-full |
424+ | -----------------------| ---------:| ---------:| ---------:| ---------:| -----------:| -----------:|
425+ | AllocFreePair/16 | 5 ns | 4 ns | 7 ns | 3 ns | 13 ns | 23 ns |
426+ | AllocFreePair/64 | 5 ns | 4 ns | 7 ns | 4 ns | 13 ns | 21 ns |
427+ | AllocFreePair/1024 | 5 ns | 6 ns | 10 ns | 3 ns | 12 ns | 20 ns |
428+ | AllocFreePair/4096 | 16 ns | 11 ns | 12 ns | 4 ns | 1.60 µs | 1.58 µs |
429+ | AllocFreePair/65536 | 16 ns | 262 ns | 20 ns | 4 ns | 1.01 µs | 1.01 µs |
430+ | AllocTouchFree/65536 | 856 ns | 1.07 µs | 870 ns | 844 ns | 15.29 µs | 15.33 µs |
431+ | BatchAllocFree/1024 | 10.07 µs | 11.38 µs | 5.78 µs | 3.99 µs | 33.09 µs | 34.98 µs |
432+ | BatchAllocFree/8192 | 78.04 µs | 95.25 µs | 51.68 µs | 33.53 µs | 541.00 µs | 560.86 µs |
433+ | MixedPareto | 28.43 µs | 12.00 µs | 13.42 µs | 12.25 µs | 43.03 µs | 46.14 µs |
434+ | ReallocGrow | 232 ns | 3.79 µs | 16.84 µs | 15.12 µs | 250.40 µs | 250.06 µs |
435+
436+ ### Fragmentation
437+
438+ Each backend's own stats API gives "committed bytes" after the
439+ workload runs; no ` /proc/self/statm ` reads. ` mimalloc ` is shown as
440+ n/a because the public ` mi_stats_get ` API in 3.3.0 has a header /
441+ source ABI mismatch that makes its committed-bytes counter unusable
442+ from a consumer.
443+
444+ | Workload | live | glibc | jemalloc | tcmalloc | misra-fast | misra-full |
445+ | ----------------------------| -------:| ---------:| ---------:| ---------:| -----------:| -----------:|
446+ | Frag_Checkerboard/16384 | 2.5 MB | 4.07 MB | 11.85 MB | 7.00 MB | 10.10 MB | 10.10 MB |
447+ | Frag_Checkerboard/65536 | 10 MB | 15.27 MB | 22.18 MB | 16.00 MB | 40.23 MB | 40.23 MB |
448+ | Frag_LifetimeMix | ~ 4 MB | 5.05 MB | 20.64 MB | 16.00 MB | 45.84 MB | 45.84 MB |
449+ | Frag_PageOverhang | 18.2 MB | 20.51 MB | 32.46 MB | 29.00 MB | 77.00 MB | 77.00 MB |
450+
451+ ### Reading the numbers
452+
453+ - ` misra-fast ` vs ` misra-full ` is exactly the cost of
454+ ` heap_validate_self_full ` versus ` _fast ` . The full path adds
455+ vtable / alignment / per-class invariant cross-checks + four forced
456+ volatile cache-line probes per dispatch (~ 10 ns / call). The fast
457+ path keeps only the magic check on the allocator and on the
458+ embedded ` PageAllocator ` . The ` heap_validate_full ` meson option
459+ picks one at library build time. The hot bin / free-list code is
460+ byte-identical between the two -- only validation differs, so
461+ fragmentation is also byte-identical between the two columns.
462+
463+ - Small allocations (< 2 KiB): ` misra-fast ` is ~ 2.5× glibc, in the
464+ same range as jemalloc and mimalloc on this workload. tcmalloc's
465+ thread-local cache makes it the outright winner.
466+
467+ - Large allocations (>= 4 KiB): ` HeapAllocator ` routes everything
468+ above the 2 KiB bin straight to ` mmap ` / ` munmap ` per call by
469+ design (no large-alloc cache; the README's "every allocator is a
470+ plain stack-allocated struct, no hidden globals" property would
471+ not survive a thread-local large-block cache). The other
472+ allocators retain freed large blocks in per-thread arenas and so
473+ serve them back without a syscall.
474+
475+ - Fragmentation: ` HeapAllocator ` partitions each 4 KiB user page into
476+ fixed sub-regions (e.g. 8×128 B + 4×256 B + 4×512 B in the medium
477+ class). When a workload uses only one sub-region size, the rest of
478+ the page stays committed but unused. The general-purpose allocators
479+ use variable-size bins and pack better on these patterns.
480+
481+ ### Reproducing
482+
483+ The benchmark harness is a separate project that links MisraStdC plus
484+ the four reference allocators. The shape of each binary:
485+
486+ ``` c
487+ // per benchmark binary, one of:
488+ // bench-glibc -- links nothing extra; default malloc resolution
489+ // bench-jemalloc -- -ljemalloc; libjemalloc.so interposes malloc
490+ // bench-mimalloc -- -lmimalloc; libmimalloc.so interposes malloc
491+ // bench-tcmalloc -- -ltcmalloc; libtcmalloc.so interposes malloc
492+ // bench-misra-fast -- -lmisra_std at -Dheap_validate_full=false
493+ // bench-misra-full -- -lmisra_std at -Dheap_validate_full=true
494+ ```
495+
496+ Each binary runs the same Google Benchmark suite; the only difference
497+ is which ` malloc ` resolves (for the four ` libc ` -shape backends) or
498+ which ` libmisra_std.a ` variant is linked (for the two misra binaries).
499+ The misra binary calls ` HeapAllocator ` 's typed ` AllocatorAlloc ` /
500+ ` AllocatorFree ` directly -- not the libc ` malloc ` shim.
501+
502+ Three workload categories are exercised:
503+ - Synthetic timing micro-benchmarks (` BM_AllocFreePair ` ,
504+ ` BM_BatchAllocFree ` , ` BM_AllocTouchFree ` , ` BM_ReallocGrow ` ).
505+ - A Pareto-distributed mixed-size workload (` BM_MixedPareto ` ).
506+ - Fragmentation-exposing patterns (` BM_Frag_* ` ): checkerboard frees,
507+ long-running lifetime mix, page-boundary overhang allocations.
508+
509+ Footprint is read through each allocator's own introspection API
510+ (` mallinfo2 ` for glibc, ` mallctl("stats.mapped") ` for jemalloc,
511+ ` MallocExtension_GetNumericProperty("generic.heap_size") ` for
512+ tcmalloc, the embedded ` PageAllocator.entries[] ` for misra) so the
513+ metric is the allocator's own bookkeeping, not process-wide RSS.
514+
515+ ---
516+
375517## Six Core Ideas
376518
377519### 1. Allocators define shared lifetimes, not per-object state
0 commit comments