Skip to content

TLAB: direct-mapped superblock cache to fix scattered-free thrash - #123

Merged
emeryberger merged 1 commit into
masterfrom
perf/tlab-direct-mapped-superblock-cache
Jul 19, 2026
Merged

TLAB: direct-mapped superblock cache to fix scattered-free thrash#123
emeryberger merged 1 commit into
masterfrom
perf/tlab-direct-mapped-superblock-cache

Conversation

@emeryberger

Copy link
Copy Markdown
Owner

What

The TLAB free fast path cached exactly one superblock's read-only header fields, so it could normalize a pointer and account for the freed object without touching the (cold) superblock header. That single entry thrashed whenever consecutive frees hit different superblocks: larson with sizes spanning several classes (e.g. 100–200) keeps ~14 superblocks live at once, so every free missed the cache and reloaded ~7 cold header fields + recomputed the multiplicative-inverse modulo. This was the documented larson-100-200 gap (~65% of mimalloc).

This replaces the single entry with a 16-way direct-mapped set indexed by superblock number ((addr / SuperblockSize) & 15). Each 64-byte entry (padded to a power of two so indexing is a shift, not a multiply) caches the same fields, so the whole bounded working set of a scattered free stream lives in the set at once and those frees hit instead of thrashing. Hit-path shape is unchanged — one indexed load + predicted-taken compare, then the same combined remote/threshold branch and inline foreign-free — factored into an always-inlined applyFree().

Only tlab.h changes (+126/−117).

Results (interleaved A/B; higher = better unless noted)

workload baseline this PR vs baseline
larson 100-200 (macOS/arm64 M1) ~257 M ops/s ~293 M ops/s +15% (72%→83% of mimalloc)
larson 100-200 (Linux/x86-64) ~63 M ops/s ~69 M ops/s +8% (74%→81% of mimalloc)
larson 7-8 (canonical) within noise (tied)
linux-scalability (1t recycle) tied
threadtest (8t) tied

Ways sweep (interleaved, Linux): 8-way ties baseline (thrashes against the ~14 live superblocks), 16-way = +8%, 32-way identical to 16 for double the per-thread footprint. 16 is the sweet spot (16×64B = 1 KB/thread).

Caveats / validation notes

  • The larson 7-8 effect swings within ±5% run-to-run (interleaved samples ranged 0.94–1.04× vs baseline); the cleanest fully-idle run showed it tied. No robust regression, but the CI qos-larson gate is the arbiter — I could not get a clean absolute hoard-vs-mimalloc gate ratio locally because the benchmark box's mimalloc numbers had 12–38% spread (the gate is report-only on high-variance runners for exactly this reason).
  • Correctness verified across threadtest / larson (7-8 and 100-200) / linux-scalability / cache-thrash / cache-scratch, single- and multi-threaded, on macOS and Linux.

Dead-ends ruled out (so they aren't re-attempted)

  • Shrink-wrapping the entry stack frame — the compiler reconstructs the frame every way tried; neutral-to-worse.
  • An L0 scalar MRU in front of the set (to protect the single-superblock path) — the extra L0 compare taxes every scattered free by ~the size of the win, neutralizing the 100-200 gain, whether or not the L0 branch carries a prediction hint.

🤖 Generated with Claude Code

The free fast path cached exactly one superblock's read-only header
fields, so it could normalize a pointer and account for the object
without dereferencing the (cold) superblock header. That single entry
thrashed whenever consecutive frees hit *different* superblocks: larson
with sizes spanning several classes (e.g. 100-200) keeps ~14 superblocks
live at once, so every free missed the cache and reloaded ~7 cold header
fields plus recomputed the multiplicative-inverse modulo.

Replace it with a 16-way direct-mapped set indexed by superblock number
((addr / SuperblockSize) & 15). Each 64-byte entry (padded to a power of
two so indexing is a shift, not a multiply) caches the same fields, so
the whole bounded working set of a scattered free stream lives in the set
at once and those frees hit instead of thrashing. The hit path is
unchanged in shape -- one indexed load + predicted-taken compare, then
the same combined remote/threshold branch and inline foreign-free -- via
a factored, always-inlined applyFree().

Measured (interleaved A/B, macOS/arm64 M1 Max and Linux/x86-64):
  larson 100-200:  +15% (macOS), +8% (Linux)  [72% -> 80-83% of mimalloc]
  larson 7-8:      within noise (tied)
  linux-scalability, threadtest: tied

16 ways is the sweet spot: 8 thrashes against the ~14 live superblocks
(no win), 32 is identical to 16 for double the footprint (1KB/thread).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@emeryberger
emeryberger merged commit 26a0e7c into master Jul 19, 2026
5 checks passed
@emeryberger
emeryberger deleted the perf/tlab-direct-mapped-superblock-cache branch July 19, 2026 14:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant