v0.9.1 — Inline Backtrace Capture
Pre-releasemod-alloc v0.9.1 — Inline Backtrace Capture
Date: 2026-05-14
Compare: v0.9.0...v0.9.1
Headline
Tier 2 lands. With --features backtraces and frame pointers
enabled, every tracked allocation captures up to 8 frames of its
call site via inline frame-pointer walking. Per-call-site
aggregation is exposed through a new ModAlloc::call_sites()
API. Zero external dependencies — no backtrace crate, no
addr2line, no gimli. Symbolication ships in v0.9.2.
What's new
Public API additions
unsafe-free hot path additions toGlobalAlloc.alloc,
alloc_zeroed, andreallocnow invoke
backtrace::record_eventafter the existing counter update
when thebacktracesfeature is on.deallocdoes not capture
(matches dhat: call sites describe who allocated, not who
freed).ModAlloc::call_sites() -> Vec<CallSiteStats>drains the
per-call-site aggregation table into a vector for inspection
or reporting. Behind#[cfg(feature = "backtraces")].CallSiteStatspublic type carryingframes: [u64; 8],
frame_count: u8,count: u64,total_bytes: u64. Frames are
raw return addresses; symbolication lands inv0.9.2.
Capture mechanism
- Inline
core::arch::asm!reads the FP register (rbpon
x86_64,x29on aarch64). Noextern "C"stubs, no build.rs
code generation, nobacktracecrate. Stable since Rust 1.59,
well under MSRV 1.75. - Pure-Rust FP walk with five mandatory hardening checks per
iteration (null, alignment, in-range, monotonicity, max-frame
cap). The walker is total: for any input bytes it terminates
within 8 iterations without UB. Proven via SplitMix64-driven
random-workload fuzz (10,000 iterations) in
tests/backtrace_fuzz.rs. - Per-thread stack-bounds discovery queries the OS once per
thread, caches in TLS.GetCurrentThreadStackLimitson
Windows,pthread_getattr_npon Linux,
pthread_get_stackaddr_npon Darwin / *BSD. All inline
extern "C"/extern "system"; nolibccrate.
Storage architecture
- Per-thread arena. 64 KB OS-page region per thread (via
VirtualAllocon Windows,mmapon POSIX), 512 events per
flush. Flushed synchronously when full, plus on thread exit via
a TLSDropimpl. - Global aggregation table. Fixed-size, open-addressed,
atomic-only hash table. Default 4,096 buckets × 96 bytes ≈
384 KB. Allocated lazily once via raw OS pages on the first
event recorded. Override viaMOD_ALLOC_BUCKETSenv var at
process start (clamped[64, 1_048_576], rounded up to next
power of two). - Inline FxHash variant for the frame-array hash. Eight words
of input, two ops per word, no memory traffic. Hash value 0 is
remapped to 1 so the bucket's atomic hash field can use 0 as
the "empty" sentinel.
Reentrancy safety
The walker reads memory only inside the cached stack bounds and
only at 16-byte-aligned addresses, which avoids page faults by
construction. The existing IN_ALLOC reentrancy guard from
v0.9.0 covers any pathological allocation triggered
transitively from inside the backtrace path (e.g. libc lazy-init
during the very first pthread_getattr_np). No new mechanism
introduced.
Concurrency
Each bucket uses a two-phase publish protocol: CAS on hash
first (Release ordering) to claim the slot, then plain stores of
sample_frames, then frame_count.store with Release to mark
the bucket fully populated. Readers gate on frame_count > 0
after observing a non-zero hash; this prevents torn reads of the
sample frames. Validated by the 32-thread aggregation stress
test (tests/backtrace_concurrent.rs).
Test additions
src/backtrace/*unit tests: hash determinism + clamping +
collision floor, walker null / alignment / range / monotonic /
max-frame stops, arena round-trip, table single-site and
multi-site aggregation, raw-mem alloc / free, stack-bounds
discovery sanity.tests/backtrace_real_chain.rs— captures from a chain of
#[inline(never)]functions ending inVec::with_capacity.tests/backtrace_fuzz.rs— SplitMix64 PRNG drives 10,000
randomly-sized allocations + occasional drop chains and
format-heavy paths. Verifies the walker is total.tests/backtrace_concurrent.rs— 32 threads × 1,000 allocations
across distinct call paths. Verifies the table claim races
resolve without deadlock or drop.
Example
examples/backtraces.rs— installsModAllocas
#[global_allocator], runs three known call paths
(alloc_small/alloc_medium/alloc_large), and prints
the top sites bytotal_bytes.
Tooling
build.rs(one-off approved exception per
.dev/DIRECTIVES.mdsection 2.1). Detects whetherRUSTFLAGS
includes-C force-frame-pointers=yeswhen thebacktraces
feature is on and emitscargo:warning=if not. Never fails
the build; the walker degrades gracefully..cargo/config.tomlin the crate root enables frame
pointers for the crate's own builds (tests, examples,
doctests). Downstream consumers must set the flag in their own
build.- CI ASAN nightly job runs the test suite under
-Zsanitizer=addresswith-Zbuild-stdon Linux x86_64.
Cheap insurance for the unsafe walker path.
Design notes
Per-thread arena is now load-bearing
v0.9.0 deferred per-thread buffering because four u64
counters do not benefit from amortising indirection. v0.9.1
captures up to 80 bytes per event (8 × 8-byte frames + size +
frame_count). At that size, going through a global lock-free
queue per allocation would serialise the hot path. The per-
thread arena absorbs 512 events between flushes, amortising the
hash + table-insert cost across the batch.
Why VirtualAlloc / mmap instead of Vec
The backtrace path runs inside the alloc hook. Allocating its
own state through ModAlloc::alloc would recurse (the existing
guard catches it, but the storage would silently bypass tracking
and we want clean accounting). Raw OS pages avoid this entirely.
Capture-on-realloc matches dhat
Per maintainer guidance, realloc captures all events including
shrinks. This matches dhat's per-event accounting and preserves
v0.9.3's dhat-compat compatibility. Documented in the rustdoc.
Configuration is runtime, not build-time
MOD_ALLOC_BUCKETS is read at runtime via std::env::var
inside the global table init path. This is intentional: a
build-time constant would force users to recompile to change the
table size, and a build.rs doing env-var reads would conflict
with our zero-build-script policy. The runtime read happens once
per process under the reentrancy guard.
Migration
Default builds are unchanged. The counters Tier 1 path is
identical to v0.9.0. No code edits needed.
Users opting in to backtraces:
[dependencies]
mod-alloc = { version = "0.9", features = ["backtraces"] }# .cargo/config.toml
[build]
rustflags = ["-C", "force-frame-pointers=yes"]Without the rustflag, build.rs emits a cargo:warning= and the
walker captures zero or one frame at runtime.
Verification
Full matrix run on Windows host (x86_64):
cargo build(default features)cargo build --no-default-featurescargo build --features counterscargo build --features backtracescargo build --features dhat-compat(still no-op as documented)cargo build --all-featurescargo +1.75 build --all-features(MSRV)cargo fmt --all -- --checkcargo clippy --all-targets --all-features -- -D warningscargo test --all-features: 54/54 pass (32 unit + 4
v0.9.1 integration + 4 v0.9.0 integration + 5 smoke + 9
doctests)- Banned-word scan (
comprehensive,robust,seamless,
leverage): 0 hits across shipping files - Em-dash scan: 0 hits across shipping files
CI matrix unchanged set (ubuntu-latest, macos-latest,
windows-latest) plus new ASAN nightly Linux job.
Measured overhead
cargo run --release --features backtraces --example bench_overhead
(1,000,000 × 64-byte alloc / dealloc cycle):
| Build | Per cycle |
|---|---|
v0.9.0 baseline (counters only) |
34.9 ns |
v0.9.1 with backtraces |
1,915 ns |
Net backtrace overhead: ~1,880 ns per allocation. REPS
section 6 sets the Tier 2 target at <200 ns. We are roughly 10x
over.
Likely contributors (not investigated in this milestone):
read_volatilein the walker prevents reordering — intentional
for the safety contract, but costly.thread_local!try_withclosure overhead insideIN_ALLOC
and the arena slot access.- Atomic-store loop in arena append (writes 80 bytes as 10
AtomicU64stores rather than amemcpy). - Spin-wait in
table::wait_publishedeven when the bucket has
long since been published.
The arithmetic suggests several of these are individually
optimisable; the headline figure is honest end-to-end including
all kernel/OS noise. Treated as a known limitation; an
optimisation pass is filed as a separate milestone (see
"Follow-ups" below) so this release can ship and unblock
downstream work.
Capture quality on stock builds
Default Rust release builds compile std without frame pointers.
The walker correctly stops at the first FP-less frame rather than
crashing, which on a typical workload produces 1-frame traces
(the immediate caller of GlobalAlloc::alloc inside std's
allocation infrastructure).
For deep, application-meaningful traces, downstream users need
either:
cargo +nightly -Z build-std=stdwith
-C force-frame-pointers=yes(rebuildstdwith FPs), or- A toolchain image built with FPs enabled application-wide.
v0.9.2's symbolication work may explore frame-pointer-less
unwinding (SEH on Windows, DWARF .eh_frame on POSIX) but that is
a separate scoping decision.
Limitations
- Performance is 10x over the REPS Tier 2 target. See
"Measured overhead" above. Functional correctness verified;
micro-optimisation deferred. - Backtraces are shallow without an FP-built std. Documented
above and in the rustdoc. - Hash collisions are not detected. Two distinct call sites
with identical 64-bit FxHash values get conflated in the
aggregation table. Statistically rare; not a correctness issue
but a visibility one. - Table-full events are silently dropped. When the open-
addressed table reaches full load (4,096 distinct sites by
default), new sites are dropped. Adropped_entriesdiagnostic
counter is planned forv0.9.2'sDiagnosticsAPI. - TLS destructor coverage. The per-thread arena's flush-on-
exit relies onthread_local!destructor invocation. On abort
(std::process::exit), destructors do not run and the last
N events on each thread remain unflushed. Acceptable; the
global table holds the bulk.
Downstream impact
dev-bench MSRV unblock: still in progress. v0.9.1 does not
complete the dhat replacement (DHAT-format output is v0.9.3),
but it ships the capture mechanism dev-bench will eventually
read from. The overhead figure above means dev-bench should
not enable backtraces for every benchmark run; gating it
behind a bench-with-traces profile keeps the default fast.
Strategy context:
C:\Dev\libraries\Rust\_strategy\MSRV_AUDIT_DEV_LIBS.md.
Follow-ups
- Optimisation pass to close the 10x perf gap. Candidates
listed in "Measured overhead". Suggested as av0.9.1.1patch
release or folded intov0.9.2's symbolication work. DiagnosticsAPI (Vec<DroppedEntry>, table-full counter,
per-bucket fill rate) deferred tov0.9.2.- Symbolication is
v0.9.2's entire scope.
Release ceremony
Standard pattern:
git tag -a v0.9.1 -m "Release v0.9.1 - inline backtrace capture"git push origin maingit push origin v0.9.1cargo publish --dry-run --all-featurescargo publish- Confirm
https://crates.io/crates/mod-alloc/0.9.1is live.
GitHub release title: v0.9.1 — Inline Backtrace Capture.
Tag as pre-release; 1.0.0 waits for v0.9.4 (dev-bench
integration) to ship and bake.
Full Changelog: v0.9.0...v0.9.1