v0.9.5 — Perf Optimization #6
jamesgober
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
mod-alloc v0.9.5 — Tier 2 perf optimisation (~32x speedup)
Date: 2026-05-18
Compare:
v0.9.4...v0.9.5Headline
Tier 2 (
backtraces) per-allocation overhead dropped from~2,050 ns to ~57 ns on a Windows x86_64 dev host — roughly 32x
faster, comfortably under the REPS section 6 target of <200 ns
of Tier 2 overhead. The dominant cost was the per-thread arena
layer that buffered captured events between flushes; v0.9.5
removes the arena entirely and writes each captured event
straight to the global aggregation table.
Both tiers now sit well inside their REPS targets with
significant headroom for production use.
What changed
Bench numbers
counters, default)backtraces)Bench command:
cargo run --release --features backtraces --example bench_overheadREPS section 6 targets: Tier 1 <50 ns total, Tier 2 <200 ns of
additional overhead. Both clear.
Removed: the per-thread arena layer
src/backtrace/arena.rsis deleted. Previously, each capturedevent went through:
record_event(size)insrc/backtrace/mod.rsARENAslot.mmap/VirtualAllocof a 64 KB arena page on firstevent (one-time per thread).
Framesinto the arena'sentry struct at the cursor.
entries into the global table via
table::record.Steps 2, 4, and 6 carried real cost on Windows where
thread_local!access is ~30–60 ns per lookup and an unbatchedmemcpyof 72 bytes is ~10 ns. The flush was bounded(~70–100 ns per entry × 512 entries = ~50 µs per flush,
amortising to ~100 ns per event), so the visible per-event cost
was dominated by TLS and memcpy.
The arena's rationale was contention reduction under concurrent
allocation. Measurement showed that
table::record'ssteady-state matching path is just two atomic operations
(
fetch_addoncountandtotal_bytes) on the bucket theevent hashes to. Linear probing in the 4,096-bucket default
table spreads writes across cache lines well; the per-thread
arena's batching no longer paid for itself once buckets were
warm (which happens after the very first event per call-site
per process).
Architecture after v0.9.5
Tier 2 hot path: 2 TLS lookups total (down from 3), and no
inter-event buffering. The 72-byte arena memcpy and the
periodic flush blockage are both gone.
Inlining hints
table::recordis now#[inline]— without this, thin-LTOcould not stitch the cross-module call into
record_event.current_stack_boundsis now#[inline(always)]— the cache-hit fast path folds into the calling
record_eventbody.ensure_initintable.rsis split into a hot#[inline(always)]accessor (three atomic loads for thesteady state) and a cold
#[cold] #[inline(never)]slow paththat handles the one-time-per-process table allocation.
Together these let the optimizer reduce
record_eventto anear-straight-line function inside
ModAlloc::alloc.Removed items
src/backtrace/arena.rs(deleted)ENTRIES_PER_ARENAconstantArenaState,ArenaSlotstructsARENAthread-localarena::record_event,arena::flush_current_threadrecord_and_flush_round_triparena unit testsuper::arena::flush_current_thread()call insidetable::call_sites_report(there is nothing to flush)Test additions / changes
(52 unit + 1 + 1 + 1 + 1 + 1 + 7 + 4 + 1 + 1 + 5 + 1 + 1 + 19
doctests) on
cargo test --all-features.call_sites(),symbolicated_report(),dhat_json_string(),write_dhat_json(), or anydhat_compat::*API.Design notes
Why the arena existed in the first place
v0.9.1's design (
.dev/DESIGN_v0.9.1.mdsection 2) anticipatedthat direct writes to a global table would contend badly under
multi-thread alloc storms — the same call site from N threads
would all CAS-claim the same bucket on the very first event,
then all hammer the bucket's
count/total_bytesatomics. Aper-thread arena was the hedge: each thread accumulates locally,
then a single thread does the bulk insert.
The hedge turned out to be unnecessary. After the first event,
the bucket's
count/total_bytesare uncontended fetch_addson a single cache line. Reading the bucket's
hashfield(Acquire-ordered) is also uncontended after publication.
Modern x86_64 handles uncontended atomic adds at ~5–10 ns each;
under contention they slow to ~30–50 ns per op, but linear
probing in a 4,096-bucket table means contention only happens
when N call sites hash to the same bucket and N threads hit
them at the same time. Measured: no observable contention spike
in the 32-thread concurrent stress test
(
tests/backtrace_concurrent.rs).Why not also combine the remaining TLS slots
After the arena removal, two TLS slots remain on the hot path:
IN_ALLOC(reentrancy) inlib.rsandCACHED(stack bounds)in
backtrace/stack_bounds.rs. Combining them into a singleTLS struct would save one TLS lookup per allocation — at most
30–60 ns on Windows. The bench already shows we are 17 ns over
Tier 1 (which itself uses
IN_ALLOC), so the actual remainingTier 2-specific TLS cost is at most ~30 ns. Combining would
require either touching the
IN_ALLOCflag from inside thebacktrace module (layering inversion) or duplicating it. Not
worth the architectural cost for ~30 ns; revisit if a future
target tightens further.
Why
#[inline(always)]and not#[inline]everywhere#[inline]is a suggestion;#[inline(always)]is a directive.For functions on the alloc hot path that are called from exactly
one place in release builds (
current_fp,record_event,current_stack_bounds's fast path),#[inline(always)]iscorrect — there is no risk of code bloat from multiple
expansions because there are no multiple expansions. For
functions called from a few places (
table::record), plain#[inline]lets the compiler decide.ensure_init_slowandquery_os(the cfg-specific OS callersin
stack_bounds.rs) are#[cold] #[inline(never)]. They runexactly once per process and once per thread respectively; we
want them out of the hot path's icache.
Migration
None. No public API changes. No new features. No new
dependencies. MSRV unchanged (
1.75).The internal hot-path restructure is invisible to callers:
ModAlloc::call_sites()returns the same dataModAlloc::symbolicated_report()returns the same dataModAlloc::dhat_json_string()/write_dhat_json()producethe same JSON
dhat_compat::HeapStats::get()/Profiler::*/Allocsurface unchanged
MOD_ALLOC_BUCKETSenv var still configures the table at thesame level (4,096 buckets default)
Verification
Full matrix run on Windows host (x86_64):
cargo build(default features): ✓cargo build --no-default-features: ✓cargo build --features counters: ✓cargo build --features backtraces: ✓cargo build --features symbolicate: ✓cargo build --features dhat-compat: ✓cargo build --all-features: ✓cargo +1.75 build --all-features(MSRV): ✓cargo fmt --all -- --check: ✓cargo clippy --all-targets -- -D warnings: ✓cargo clippy --all-targets --all-features -- -D warnings: ✓cargo clippy --all-targets --no-default-features -- -D warnings: ✓cargo doc --no-deps: ✓cargo doc --all-features --no-deps: ✓cargo test --all-features: 96/96 passcargo run --release --features backtraces --example bench_overhead:56.9 ns / cycle (down from ~2,051 ns)
CI matrix: ubuntu-latest, macos-latest, windows-latest plus the
ASAN nightly Linux job. No new CI step required; existing
--features backtracestest step already exercises the hotpath. The 32-thread concurrent stress test
(
tests/backtrace_concurrent.rs) passes unchanged, confirmingthe direct-write path scales under multi-thread allocation.
Version-numbering note
mod-alloc has no v0.9.5 entry corresponding to the
"dev-bench swap" milestone in the roadmap — that milestone
shipped as
dev-bench v0.9.7(the consumer-side swap lives inthe dev-bench repo, not mod-alloc). This release reuses the
v0.9.5slot for mod-alloc's own published version sequence oncrates.io: 0.9.4 → 0.9.5.
Follow-ups
perf target was the last REPS section 6 gating item before
freeze. v1.0.0 prep: full public-API rustdoc audit, freeze
the wire format, confirm zero-friction with dev-bench v0.9.7
(the live consumer).
IN_ALLOC+CACHEDTLS slots. Worth ~30 ns butcosts architectural cleanliness. Defer unless a future REPS
revision lowers the Tier 2 target.
Release ceremony
Standard pattern:
git tag -a v0.9.5 -m "Release v0.9.5 - Tier 2 perf optimisation (32x speedup)"git push origin maingit push origin v0.9.5cargo publish --dry-run --all-featurescargo publishhttps://crates.io/crates/mod-alloc/0.9.5is live.GitHub release title:
v0.9.5 — Tier 2 perf optimisation (~32x speedup).Full Changelog: v0.9.4...v0.9.5
This discussion was created from the release v0.9.5 — Perf Optimization.
All reactions