← board

Heap: segregated free lists (size classes) — kill the O(n) free-list walk

Problem — one global first-fit free list

PXXAlloc (builtinheap.pas) today: bump pointer off a big mmap arena for fresh memory (O(1), good), plus one global free list for reuse. On every alloc it walks that free list first-fit (while cur <> 0 … if PWord(cur-8)^ >= size), O(number of freed-but-unreused blocks). Two costs:

  1. O(n) walk per alloc as the free list grows (free/realloc-churny workloads: sqlite, lua, string-heavy code).
  2. Size-mismatch reuse + no coalescing — first-fit hands a 200-byte freed block to a 16-byte request (no split), and PXXFree just LIFO-pushes (never merges neighbours). Fragments over time.

It walks the free list (available chunks), NOT all heap — live memory and the bump path are untouched. The free list is already "a list of available chunks"; the fix is to keep several lists, bucketed by size.

Fix — segregated free lists (size classes)

Replace the single FreeList head with an array of heads, one per size class:

Effects: O(1) alloc/free on the common path, exact-size reuse (no fragmentation from size mismatch), and same-size blocks cluster in memory → locality as a free side effect (the "same page" goal, achieved structurally, no profile needed — size is known at the call site).

Keep invariants (correctness bar)

Not in scope

Gate (file-ownership Track A — core runtime)

builtinheap.pas is linked into the compiler itself and every compiled program, so: make test + self-host byte-identical (heap layout does not affect emitted bytes, but a heap bug crashes the compiler — verify), alloc-heavy corpus still green (sqlite :memory: + file-VFS, lua), and the 5 libc-free cross targets (the static-arena path). Land only green.

Acceptance

[[feature-opt-o3-register-pressure]] (sibling optimization ticket, orthogonal — that one is stack/codegen, this is heap/runtime) · umbrella [[feature-optimization-levels]].

Landed 2026-07-13

Implemented as specified: FreeBins[0..63], one bin per exact 8-byte class up to HEAP_BIN_MAX = 512. Alloc pops bin[size div 8 - 1] — O(1), exact fit, no walk. Free reads the size from the header at [p-8] (it is already the rounded size, so the class is recoverable) and pushes — O(1). Sizes above the cap keep the old single first-fit list, whose walk now only ever covers the rare big blocks. 64 bins x one word = 512 bytes of BSS, which the ESP static-arena build affords.

Measured (same HEAD, only builtinheap.pas differing — two compilers built and timed against each other, so this is not confounded by other changes):

workload old new
alloc/free churn (3000 live slots x 300 rounds, sizes spread over the binned range) 0.16 s 0.12 s 1.33x
compiler self-compile 3.81 s 3.57 s ~6% faster

Self-compile is the one that pays the rent — pin time is the stated bottleneck.

And the memory result is the bigger one — peak RSS, self-compile: 454 MB -> 209 MB, less than half. Not what the ticket predicted (it argued locality, not footprint), so the mechanism is worth stating: first-fit would hand a large freed block to a small request without splitting it, so the block was consumed at a fraction of its size and was never again available at its real size — the heap kept bump-allocating fresh arena for the sizes it had already thrown away. Exact-fit reuses each class from its own bin, so the arena stops growing. Same-size clustering then touches far fewer pages on top.

The alloc-churn microbench goes the other way by a trivial margin (896 KB -> 2176 KB): 64 size classes each keep their own live set, so a workload that deliberately sprays every class holds a little more. Absolute cost ~1.2 MB, and it does not scale with the program — it is bounded by the number of classes.

One deliberate behaviour change (contract, not a bug)

Reuse is now exact-fit. First-fit used to hand a freed 128-byte block to a 64-byte request and never split it: reuse, but at the cost of an O(n) walk AND permanent internal fragmentation. Exact-fit wastes nothing and the larger block simply waits for a request of its own size — which is the steady state of any real workload. test/test_freemem.pas asserted the OLD behaviour, so it was rewritten to the new contract and extended: exact-fit reuse, no size-mismatch reuse, data intact across reuse, and the large (> 512) first-fit path still serving a smaller large request.

Gate

make test green, self-host byte-identical, testmgr --tier full 1203/1203 (the alloc-heavy sqlite/lua corpora and all cross targets included — good evidence for an allocator change).