Skip to content

Commit e6c3217

Browse files
heap: 8 power-of-two classes, XL retention, footprint shrink policy
CLASSES 17 binned classes collapsed to 8 powers of two (16, 32, 64, 128, 256, 512, 1024, 2048). Every class divides HEAP_PAGE_SIZE exactly. heap_class_idx_for becomes an 8-step ladder. The L tier (768 / 1024 / 1536 / 2048) loses its non-power-of-two bins; the alignment-waste regression they kept covering with intra-page shims is now zero. XL RETENTION Replaced the xl[] open-addressed hash with two parallel flat arrays: xl_in_use[] -- live XL descriptors, xl_freed[] -- retained mappings (freed but not yet returned). Alloc looks for a matching `os_pages` entry in xl_freed (LIFO scan) and only mmaps on miss. Free swap-removes from xl_in_use and swap-pushes onto xl_freed. Linear scan on free is O(N_live); for typical XL counts (< 100) it is one contiguous read per probe, faster than the Fibonacci-hash lookup it replaced. BM_AllocFreePair 4-64 KiB drops from ~1185 ns to ~19 ns (validate-full) and ~11 ns (validate-fast). The plateau is now retention-pool reuse, not mmap/munmap syscall pairs. FOOTPRINT SHRINK POLICY Tracks retention_bytes (sum of recycle + xl_freed mappings). When footprint_bytes >= HEAP_FOOTPRINT_SHRINK_THRESHOLD (1 MiB) AND retention_bytes > footprint_bytes / 2, the deallocate path unmaps xl_freed entries (then recycle entries) until footprint drops to 75% of its old value. Fast check is FORCE_INLINE'd at the call site so the common case (no shrink needed) is two loads + two compares. CACHE-LINE ALIGNMENT `_Alignas(64)` on HeapPage's first member (portable C11, gcc / clang / clang-cl / MSVC) so each pages[] hash bucket fits on one cache line. Without this the natural 56-byte struct leaves bucket tails bleeding into the next line, doubling probe cost on the classed free/grow hot paths. KEEP-ONE-WARM GUARD The pre-rewrite reclaim path had a class_count[cls] > 1 check that kept the last warm page parked. That check was dropped in the initial commit and BM_AllocFreePair regressed by 12 ns because every iteration paid a recycle-pop + HeapPage descriptor re-stamp (two 32-byte AVX moves). Reinstated using just the warm list -- if this page is the warm-list head AND has no next link, reclaiming would leave the class with no warm page. Costs two loads and a compare; no new state. DROPPED - class_count[HEAP_NUM_CLASSES] - HeapPage._pad0 / _pad1[12] - heap_xl_hash_* helpers (Fibonacci hash + back-shift delete + resize, all keyed on the user pointer) CONVENTION Added two rules to CODING-CONVENTIONS.md macro hygiene: - Don't pad structs by hand; trust the compiler / ABI / standard. __attribute__((aligned)) is permitted only with a documented hardware or wire-format constraint forcing the choice (HeapPage's cache-line alignment is the canonical example). - Over-engineering is not entertained; simplest design wins. SIDE-CHANNEL CLEANUP (folded reviews) - Pdb: 3-field hand-rolled name_pool descriptor + Str-internals reach collapsed into one `Str name_pool;` field. - Allocator.Heap.c: stale "sub-bin" terminology replaced with "size class". - Benchmark/Source/Fragmentation.cpp: comment reflected the new class set ("six of the eight power-of-two classes"). - README + Heap.h docs: every reference to the xl[] hash or the 17-class S/M/L taxonomy rewritten. BENCHMARK NUMBERS (BM_AllocFreePair, validate-full / validate-fast): | size | pre-rewrite | post-rewrite | |-------|-------------|--------------| | 16 B | 15.0 / 14.2 | 14.8 / 14.3 | | 256 B | 15.2 / 14.9 | 15.0 / 14.5 | | 4 KiB | 1185 / 1155 | 19.1 / 10.6 | | 64 KiB| 1193 / 1164 | 19.2 / 10.6 | Small classes recover to baseline within noise; XL 4-64 KiB at ~63x (validate-full) / ~110x (validate-fast) faster. TESTING - Linux gcc 15.2 default build: 104/104 OK. - Linux gcc 15.2 -Db_sanitize=address,undefined: 104/104 OK. - macOS arm64 (Apple clang via SSH test box): 99/99 OK.
1 parent aab155c commit e6c3217

10 files changed

Lines changed: 453 additions & 429 deletions

File tree

Benchmark/README.md

Lines changed: 33 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ Single alloc/free pair, 16 B:
1010

1111
| backend | time |
1212
|---|---:|
13-
| tcmalloc | 3.7 ns |
14-
| glibc | 7.7 ns |
15-
| jemalloc | 10.8 ns |
16-
| mimalloc | 3.8 ns |
17-
| misra (Heap, validate-full) | 15.0 ns |
18-
| misra (Heap, validate-fast) | 14.2 ns |
19-
| misra-correct (Slab, validate-full) | 14.2 ns |
20-
| misra-correct (Slab, validate-fast) | 8.7 ns |
13+
| tcmalloc | 3.4 ns |
14+
| glibc | 7.3 ns |
15+
| jemalloc | 3.5 ns |
16+
| mimalloc | 3.0 ns |
17+
| misra (Heap, validate-full) | 14.8 ns |
18+
| misra (Heap, validate-fast) | 14.3 ns |
19+
| misra-correct (Slab, validate-full) | 14.0 ns |
20+
| misra-correct (Slab, validate-fast) | 9.0 ns |
2121

2222
## Timing
2323

@@ -27,13 +27,13 @@ One `alloc(size)` immediately followed by `free(ptr)`, repeated. Hot reuse — t
2727

2828
| benchmark | glibc | jemalloc | mimalloc | tcmalloc | misra-full | misra-fast | misra-correct-full | misra-correct-fast | misra-arena | misra-page |
2929
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
30-
| 16 B | 7.7 | 10.8 | 3.8 | 3.7 | 15.0 | 14.2 | 14.2 | 8.7 | 11.8 | n/a |
31-
| 64 B | 7.5 | 11.1 | 3.8 | 3.8 | 15.5 | 14.8 | 14.1 | 8.7 | 11.7 | n/a |
32-
| 256 B | 7.5 | 11.4 | 16.7 | 3.9 | 15.2 | 14.9 | 14.0 | 8.7 | 11.7 | n/a |
33-
| 1 KiB | 19.7 | 12.4 | 17.4 | 3.8 | 14.6 | 14.6 | 13.9 | 8.6 | 11.7 | n/a |
34-
| 4 KiB | 15.5 | 16.6 | 13.1 | 4.1 | 1185.1 | 1155.4 | 13.8 | 8.6 | 11.6 | 18.5 |
35-
| 16 KiB | 16.1 | 38.7 | 23.1 | 4.0 | 1185.6 | 1162.7 | 19.0 | 19.0 | 11.6 | 18.4 |
36-
| 64 KiB | 17.5 | 531.4 | 22.1 | 4.2 | 1193.6 | 1164.4 | 19.0 | 18.9 | 11.7 | 18.3 |
30+
| 16 B | 7.3 | 3.5 | 3.0 | 3.4 | 14.8 | 14.3 | 14.0 | 9.0 | 12.0 | n/a |
31+
| 64 B | 7.2 | 3.6 | 3.2 | 3.5 | 15.0 | 14.4 | 14.0 | 9.1 | 12.2 | n/a |
32+
| 256 B | 7.2 | 3.9 | 13.7 | 3.4 | 15.0 | 14.5 | 13.9 | 9.0 | 12.4 | n/a |
33+
| 1 KiB | 19.3 | 5.5 | 15.1 | 3.4 | 15.0 | 14.5 | 13.9 | 9.1 | 12.2 | n/a |
34+
| 4 KiB | 15.1 | 10.4 | 10.6 | 3.6 | 19.1 | 10.6 | 13.9 | 9.3 | 12.2 | 19.8 |
35+
| 16 KiB | 16.2 | 18.8 | 19.6 | 3.6 | 19.2 | 10.6 | 19.3 | 19.6 | 12.3 | 19.7 |
36+
| 64 KiB | 17.2 | 267.0 | 19.6 | 3.6 | 19.2 | 10.6 | 19.2 | 19.5 | 12.3 | 20.0 |
3737

3838
_Values in ns._
3939

@@ -43,9 +43,9 @@ _Values in ns._
4343

4444
| benchmark | glibc | jemalloc | mimalloc | tcmalloc | misra-full | misra-fast | misra-correct-full | misra-correct-fast | misra-arena | misra-page |
4545
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
46-
| 128 × 64 B | 1.4 | 1.3 | 0.5 | 0.5 | 1.6 | 1.6 | 1.8 | 1.2 | n/a | n/a |
47-
| 1024 × 64 B | 11.2 | 24.9 | 4.2 | 4.3 | 13.0 | 12.8 | 18.8 | 15.4 | n/a | n/a |
48-
| 8192 × 64 B | 87.9 | 109.1 | 37.9 | 43.6 | 104.9 | 101.2 | 401.1 | 375.0 | n/a | n/a |
46+
| 128 × 64 B | 1.4 | 0.5 | 0.4 | 0.5 | 1.6 | 1.5 | 1.9 | 1.2 | n/a | n/a |
47+
| 1024 × 64 B | 10.9 | 10.8 | 3.6 | 3.9 | 12.4 | 12.2 | 18.9 | 14.8 | n/a | n/a |
48+
| 8192 × 64 B | 87.3 | 88.7 | 33.2 | 38.9 | 99.6 | 98.8 | 399.5 | 373.9 | n/a | n/a |
4949

5050
_Values in us._
5151

@@ -55,9 +55,9 @@ Same shape as the pair test but writes every byte of the allocation before freei
5555

5656
| benchmark | glibc | jemalloc | mimalloc | tcmalloc | misra-full | misra-fast | misra-correct-full | misra-correct-fast | misra-arena | misra-page |
5757
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
58-
| 64 B | 8.1 | 5.6 | 3.5 | 4.6 | 15.5 | 15.2 | 16.7 | 11.0 | 13.8 | n/a |
59-
| 4 KiB | 41.4 | 29.6 | 27.7 | 21.8 | 2389.9 | 2375.9 | 33.8 | 28.3 | 54.3 | 38.4 |
60-
| 64 KiB | 1290.2 | 1285.7 | 1001.9 | 977.8 | 13254.3 | 13046.3 | 848.2 | 849.2 | 915.5 | 848.8 |
58+
| 64 B | 7.8 | 4.4 | 3.2 | 4.4 | 15.0 | 15.4 | 16.6 | 11.4 | 14.7 | n/a |
59+
| 4 KiB | 32.1 | 23.3 | 24.0 | 18.9 | 38.1 | 30.2 | 33.6 | 29.7 | 55.3 | 38.7 |
60+
| 64 KiB | 955.9 | 1061.8 | 863.4 | 840.3 | 852.3 | 846.6 | 850.4 | 855.3 | 916.3 | 851.1 |
6161

6262
_Values in ns._
6363

@@ -67,7 +67,7 @@ _Values in ns._
6767

6868
| benchmark | glibc | jemalloc | mimalloc | tcmalloc | misra-full | misra-fast | misra-correct-full | misra-correct-fast | misra-arena | misra-page |
6969
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
70-
| Pareto(1.16, 24) | 30.3 | 15.1 | 12.8 | 12.6 | 32.8 | 32.1 | 32.9 | 33.8 | n/a | n/a |
70+
| Pareto(1.16, 24) | 30.5 | 11.0 | 10.8 | 11.4 | 24.9 | 25.2 | 25.3 | 25.6 | n/a | n/a |
7171

7272
_Values in us._
7373

@@ -77,7 +77,7 @@ Geometric realloc ladder from 8 B up to 1 MiB. Time per full ladder, lower is be
7777

7878
| benchmark | glibc | jemalloc | mimalloc | tcmalloc | misra-full | misra-fast | misra-correct-full | misra-correct-fast | misra-arena | misra-page |
7979
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
80-
| 8 B → 1 MiB | 5740.1 | 5188.4 | 22553.0 | 18382.7 | 17088.7 | 17618.5 | 16174.7 | 16072.4 | 126.0 | n/a |
80+
| 8 B → 1 MiB | 2851.4 | 3883.1 | 18015.4 | 15131.8 | 16677.8 | 16511.0 | 15854.8 | 15104.7 | 135.0 | n/a |
8181

8282
_Values in ns._
8383

@@ -88,8 +88,8 @@ Allocate N small (32 B) objects, then release them all. Arena does this as one O
8888
| benchmark | glibc | jemalloc | mimalloc | tcmalloc | misra-full | misra-fast | misra-correct-full | misra-correct-fast | misra-arena | misra-page |
8989
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
9090
| 128 × 32 B | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 0.8 | n/a |
91-
| 1024 × 32 B | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 6.2 | n/a |
92-
| 8192 × 32 B | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 49.4 | n/a |
91+
| 1024 × 32 B | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 6.5 | n/a |
92+
| 8192 × 32 B | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 52.4 | n/a |
9393

9494
_Values in us._
9595

@@ -99,11 +99,11 @@ Each allocator's own introspection API reports committed bytes after the workloa
9999

100100
| benchmark | live MB | glibc MB | jemalloc MB | mimalloc MB | tcmalloc MB | misra-full MB | misra-fast MB | misra-correct-full MB | misra-correct-fast MB | misra-arena MB | misra-page MB |
101101
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
102-
| Checkerboard (4 K small) | 0.6 | 2.1 | 9.1 | n/a | 7.0 | 0.8 | 0.8 | 0.8 | 0.8 | n/a | n/a |
103-
| Checkerboard (16 K small) | 2.5 | 4.1 | 11.9 | n/a | 7.0 | 3.2 | 3.2 | 3.1 | 3.1 | n/a | n/a |
104-
| Checkerboard (64 K small) | 10.0 | 15.3 | 21.8 | n/a | 16.0 | 12.6 | 12.6 | 12.5 | 12.5 | n/a | n/a |
105-
| Lifetime mix | 4.0 | 5.1 | 16.7 | n/a | 16.0 | 12.6 | 12.6 | 12.5 | 12.5 | n/a | n/a |
106-
| Page overhang | 18.2 | 20.4 | 32.3 | n/a | 29.0 | 34.3 | 34.3 | 34.2 | 34.2 | n/a | n/a |
102+
| Checkerboard (4 K small) | 0.6 | 2.2 | 9.1 | n/a | 7.0 | 0.8 | 0.9 | 0.8 | 0.8 | n/a | n/a |
103+
| Checkerboard (16 K small) | 2.5 | 4.1 | 11.7 | n/a | 7.0 | 3.2 | 3.2 | 3.1 | 3.1 | n/a | n/a |
104+
| Checkerboard (64 K small) | 10.0 | 15.3 | 22.0 | n/a | 16.0 | 12.5 | 12.5 | 12.5 | 12.5 | n/a | n/a |
105+
| Lifetime mix | 4.0 | 5.0 | 20.1 | n/a | 16.0 | 4.6 | 4.6 | 4.7 | 4.6 | n/a | n/a |
106+
| Page overhang | 18.2 | 20.5 | 32.8 | n/a | 29.0 | 38.4 | 38.4 | 38.4 | 38.4 | n/a | n/a |
107107

108108
## How to read
109109

@@ -143,10 +143,10 @@ Regenerates this file with measurements from the host.
143143

144144
| | |
145145
|---|---|
146-
| timestamp | 2026-05-29 07:31:28 UTC |
147-
| git rev | ac3e23856006 (r16-base) |
146+
| timestamp | 2026-05-29 12:59:55 UTC |
147+
| git rev | e837a85ac468 (feat/heap-class-shrink-xl-retention) |
148148
| host CPU | Intel(R) Core(TM) Ultra 7 165U |
149149
| kernel | Linux 6.18.28 |
150150
| compiler | gcc 14.3.0 |
151-
| build | validate-full: buildtype=release optimization=3 b_lto=False b_sanitize=[] alloc_debug=True heap_validate_full=True | validate-fast: buildtype=release optimization=3 b_sanitize=[] b_lto=False alloc_debug=True heap_validate_full=False |
151+
| build | validate-full: buildtype=release optimization=3 b_lto=False b_sanitize=[] alloc_debug=True heap_validate_full=True | validate-fast: buildtype=release optimization=3 b_lto=False b_sanitize=[] alloc_debug=True heap_validate_full=False |
152152
| reps | 10 per row (median reported) |

Benchmark/Source/Fragmentation.cpp

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,10 @@ static void BM_Frag_LifetimeMix(benchmark::State &state) {
156156
std::mt19937 rng(0xC0FFEE);
157157
std::uniform_int_distribution<int> coin(0, 1);
158158
std::uniform_int_distribution<size_t> size_pick(0, 5);
159-
// Sizes chosen to span MisraStdC Heap's three small-class bins
160-
// (16/32/64), medium-class bins (128/256/512), and one L bin
161-
// (1024). Hits jemalloc/mimalloc/tcmalloc bins similarly.
159+
// Sizes chosen to span six of MisraStdC Heap's eight power-of-two
160+
// bin classes (16/32/64/128/256/1024), skipping 512 and 2048 to
161+
// keep the working set compact. Hits jemalloc/mimalloc/tcmalloc
162+
// bins similarly.
162163
const size_t sizes[] = {16, 32, 64, 128, 256, 1024};
163164

164165
for (auto _ : state) {

CODING-CONVENTIONS.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,23 @@ up-cast.
565565
API. If a caller would need to stamp their own, the abstraction
566566
belongs as a `_Generic` dispatch or a typed helper, not as a public
567567
stamp macro.
568+
- **Don't pad structs by hand.** The compiler already inserts
569+
alignment padding correctly for the target ABI; explicit `_pad`
570+
fields are over-engineering. The C standard, the platform ABI, and
571+
the compiler are the authorities on layout. Hand-rolled padding
572+
pretends to pin the layout but actually just clutters the struct
573+
and risks getting the alignment wrong on a target the author didn't
574+
anticipate. Trust the compiler. The same rule covers other "I'll
575+
guess what the compiler will do" reach-arounds: don't second-guess
576+
the optimizer with `__attribute__((aligned))` or `__attribute__((packed))`
577+
unless you have a documented hardware or wire-format constraint
578+
forcing the choice, and write the reason inline when you do.
579+
- **Over-engineering is not entertained.** The simplest design that
580+
satisfies the requirements is the right one. Indirection layers,
581+
caches that aren't proven necessary, classes / freelists / state
582+
machines added "for symmetry" or "in case we need it later" all
583+
earn deletion at review time. If you don't have a measured reason
584+
for the complexity, it doesn't go in.
568585
569586
## Sub-range iteration
570587

Include/Misra/Parsers/Pdb.h

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
#include <Misra/Std/Allocator.h>
2929
#include <Misra/Std/Container/Buf.h>
30+
#include <Misra/Std/Container/Str.h>
3031
#include <Misra/Std/Container/Vec.h>
3132
#include <Misra/Std/Zstr.h>
3233
#include <Misra/Types.h>
@@ -103,9 +104,7 @@ typedef struct Pdb {
103104
// Owned name pool for function-name strings. `functions[i].name`
104105
// is a borrowed pointer into here; pool and entries are freed
105106
// together in `PdbDeinit`.
106-
char *name_pool; // mutable: this is the owned buffer the parser writes into; `name` fields above point into it
107-
size name_pool_size;
108-
size name_pool_used;
107+
Str name_pool;
109108
} Pdb;
110109

111110
///

0 commit comments

Comments
 (0)