Skip to content

Commit 1d8be60

Browse files
README: allocator performance section
Microbenchmark numbers vs glibc / jemalloc / mimalloc / tcmalloc on the synthetic alloc / batch / touch / realloc / pareto / fragmentation suite. Includes hardware + toolchain + allocator versions + build flags + harness shape so the numbers are reproducible by anyone who sets up the same environment. Reads alongside the existing "What it actually costs" binary-size table (Feature Flags section) and the Libc-Diet section: now there's a parallel "what it actually costs at runtime" answer for the heap allocator path.
1 parent e165a48 commit 1d8be60

1 file changed

Lines changed: 142 additions & 0 deletions

File tree

README.md

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)