Skip to content

Commit e0bf46c

Browse files
author
James Gober
committed
Milestone Update v0.9.1
1 parent 82603df commit e0bf46c

7 files changed

Lines changed: 157 additions & 52 deletions

File tree

.github/workflows/ci.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,14 @@ jobs:
9494
- uses: actions/checkout@v5
9595
- uses: dtolnay/rust-toolchain@stable
9696
- uses: Swatinem/rust-cache@v2
97-
- run: cargo doc --all-features --no-deps
97+
# Both feature variants so we catch broken intra-doc links
98+
# that resolve under one feature set but not another (e.g. a
99+
# link to a `backtraces`-gated item from the always-on module
100+
# rustdoc).
101+
- name: Doc build (default features)
102+
run: cargo doc --no-deps
103+
- name: Doc build (all features)
104+
run: cargo doc --all-features --no-deps
98105

99106
msrv:
100107
name: MSRV (Rust 1.75)

CHANGELOG.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.9.1] - 2026-05-14
11+
1012
### Added
1113

1214
- **Tier 2: inline backtrace capture (`backtraces` feature).** Each
@@ -70,6 +72,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7072
- CI workflow runs the `backtraces` test suite with
7173
`RUSTFLAGS="-C force-frame-pointers=yes"` so traces are
7274
meaningful on hosted runners.
75+
- **Walker reads are no longer `read_volatile`.** The walker reads
76+
the current thread's own stack memory after the four
77+
bounds/alignment/range/monotonicity checks; no other thread can
78+
mutate that memory mid-walk. Plain reads let the compiler
79+
schedule the loads and unblock register-allocation
80+
opportunities. Same observable behaviour, with a small per-walk
81+
speed-up.
82+
- **Table matching path no longer spins on `wait_published`.** The
83+
init thread now uses `fetch_add` (instead of `store`) for the
84+
bucket's `count` and `total_bytes` fields. Concurrent matching
85+
writers can land their increments at any moment without being
86+
clobbered, so the matching path becomes two `fetch_add` calls
87+
with no spin loop. Readers (`call_sites_report`) still gate on
88+
`frame_count > 0` for sample-frame coherence.
89+
- CI: ASAN job sets `ASAN_OPTIONS=detect_stack_use_after_return=0`
90+
so the stack-bounds test runs against the real stack rather
91+
than ASAN's fake-stack heap allocation. The walker is
92+
unaffected either way; the test just needed a real-stack
93+
context to assert against.
94+
- CI: added a defensive "Verify toolchain is fully installed"
95+
step after `dtolnay/rust-toolchain@stable` to heal the
96+
occasional macOS runner-image case where `cargo` resolves to
97+
`rustup-init`.
7398

7499
### Design notes
75100

@@ -201,6 +226,7 @@ will not work. Real implementation lands in `0.9.x` along with:
201226
- DHAT-compatible JSON output.
202227
- Statistical validation suite.
203228

204-
[Unreleased]: https://github.com/jamesgober/mod-alloc/compare/v0.9.0...HEAD
229+
[Unreleased]: https://github.com/jamesgober/mod-alloc/compare/v0.9.1...HEAD
230+
[0.9.1]: https://github.com/jamesgober/mod-alloc/compare/v0.9.0...v0.9.1
205231
[0.9.0]: https://github.com/jamesgober/mod-alloc/compare/v0.1.0...v0.9.0
206232
[0.1.0]: https://github.com/jamesgober/mod-alloc/releases/tag/v0.1.0

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mod-alloc"
3-
version = "0.9.0"
3+
version = "0.9.1"
44
edition = "2021"
55
rust-version = "1.75"
66
readme = "README.md"

README.md

Lines changed: 93 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,20 @@
2323

2424
## What it does
2525

26-
`mod-alloc` is a global-allocator wrapper that tracks every
27-
allocation and deallocation. It answers:
26+
`mod-alloc` is a `#[global_allocator]` wrapper that tracks every
27+
allocation and answers four questions for the code that runs while
28+
it is installed:
2829

2930
- **How many allocations did this code path make?**
30-
- **How many total bytes were allocated?**
31+
- **How many total bytes did it allocate?**
3132
- **What was the peak resident memory?**
32-
- **Which call-sites caused the most allocations?** (with `backtraces` feature)
33+
- **Which call sites did most of the allocating?** (with the
34+
`backtraces` feature)
3335

34-
Designed as a lean replacement for `dhat` with:
35-
36-
- **MSRV 1.75** (vs dhat's 1.85+)
37-
- **Zero external dependencies** in the hot path (no `backtrace` crate)
38-
- **Lower overhead** per allocation via purpose-built inline capture
39-
- **DHAT-compatible output** so existing viewer tools work (via the `dhat-compat` feature)
36+
The whole crate is `std`-only. No `backtrace`, no `addr2line`, no
37+
`gimli`, no `libc`. Inline frame-pointer walking on `x86_64` and
38+
`aarch64` for call-site capture; raw `mmap` / `VirtualAlloc` for
39+
the per-thread arena and the global aggregation table.
4040

4141
## Quick start
4242

@@ -49,7 +49,7 @@ static GLOBAL: ModAlloc = ModAlloc::new();
4949
fn main() {
5050
let p = Profiler::start();
5151

52-
let v: Vec<u64> = (0..1000).collect();
52+
let v: Vec<u64> = (0..1_000).collect();
5353
drop(v);
5454

5555
let stats = p.stop();
@@ -63,36 +63,98 @@ fn main() {
6363

6464
```toml
6565
[dependencies]
66-
mod-alloc = "0.9" # counters only (default)
67-
mod-alloc = { version = "0.9", features = ["backtraces"] } # + call-site capture (lands in v0.9.1)
68-
mod-alloc = { version = "0.9", features = ["dhat-compat"] } # + DHAT-format output (lands in v0.9.3)
66+
mod-alloc = "0.9" # Tier 1: counters (default)
67+
mod-alloc = { version = "0.9", features = ["backtraces"] } # Tier 2: call-site capture
68+
mod-alloc = { version = "0.9", features = ["dhat-compat"] } # Tier 3: DHAT JSON output (v0.9.3)
69+
```
70+
71+
| Feature | What it adds | Status |
72+
|---------------|---------------------------------------------------------|--------|
73+
| `counters` | Four lock-free counters via `GlobalAlloc` (default) | shipped (v0.9.0) |
74+
| `backtraces` | Inline FP walk + per-call-site aggregation | shipped (v0.9.1) |
75+
| `dhat-compat` | Emit JSON for the official DHAT viewer | planned (v0.9.3) |
76+
77+
## Backtraces
78+
79+
Enabling the `backtraces` feature requires frame pointers in the
80+
caller's build:
81+
82+
```toml
83+
# .cargo/config.toml
84+
[build]
85+
rustflags = ["-C", "force-frame-pointers=yes"]
86+
```
87+
88+
The crate's `build.rs` emits a `cargo:warning=` at compile time if
89+
`RUSTFLAGS` is missing this. Without it the walker degrades
90+
gracefully (returns shallow or empty traces) but does not crash.
91+
92+
The aggregation-table size is configurable at process start:
93+
94+
```bash
95+
MOD_ALLOC_BUCKETS=16384 ./your-binary
6996
```
7097

98+
Default is 4,096 buckets (~384 KB). Range `[64, 1_048_576]`,
99+
rounded up to the next power of two.
100+
101+
## Performance
102+
103+
Measured per allocation, end to end, on a Windows x86_64 dev host
104+
with `cargo run --release --example bench_overhead`:
105+
106+
| Build | Per alloc + dealloc cycle |
107+
|----------------------------------------|--------------------------:|
108+
| Tier 1 only (`counters`, default) | **34.9 ns** |
109+
| Tier 1 + Tier 2 (`backtraces`) | **~1,950 ns** |
110+
111+
Tier 1 comes in well under the 50 ns target from the spec
112+
([`REPS.md`](REPS.md) section 6). Tier 2 is currently above the
113+
200 ns target in that section; closing that gap is tracked for
114+
v0.9.1.1. The Tier 2 path is correct and recursion-safe in the
115+
current release; the optimisation is a separate, focused pass.
116+
71117
## Why a new allocation profiler
72118

73-
`dhat` is the de-facto standard but its dependency chain
74-
(`backtrace` 0.3.76 → `addr2line` 0.25.1) locks consumers at Rust
75-
1.85. For projects with broader MSRV targets, this is a real cost.
119+
`dhat` is the de facto standard for allocation profiling in Rust,
120+
but its dependency chain (`backtrace 0.3.76``addr2line 0.25.1`)
121+
forces consumers to MSRV `1.85`+. For projects with a broader MSRV
122+
target, that cost is real.
76123

77-
`mod-alloc` provides the same core capability with inline backtrace
78-
capture (frame-pointer-based, x86_64 + aarch64 initially) and no
79-
external dependencies. The trade: fewer architectures supported in
80-
v1.0; we add ARM32, RISC-V, etc. based on demand.
124+
`mod-alloc` provides the same core capability with inline
125+
backtrace capture (frame-pointer-based, `x86_64` + `aarch64`) and
126+
no external dependencies. The trade-off is fewer architectures
127+
supported in `1.0`; ARM32, RISC-V, and others land based on
128+
demand.
81129

82130
## Status
83131

84-
`v0.9.0` ships Tier 1 (counters). Installing `ModAlloc` as
85-
`#[global_allocator]` tracks every allocation, deallocation,
86-
reallocation, and zero-init allocation against four lock-free
87-
atomic counters. Per-allocation overhead measures under 50 ns on
88-
x86_64 (`cargo run --release --example bench_overhead`). Tier 2
89-
(inline backtrace capture) lands in `v0.9.1`. Tier 3
90-
(DHAT-compatible JSON output) lands in `v0.9.3`. The `1.0` release
91-
freezes the public API and the wire format.
132+
| Milestone | Version | State |
133+
|--------------------------------------------|----------|----------|
134+
| Name-claim placeholder | `v0.1.0` | shipped |
135+
| Real `GlobalAlloc` + Tier 1 counters | `v0.9.0` | shipped |
136+
| Tier 2: inline backtrace capture | `v0.9.1` | shipped |
137+
| Tier 2 perf optimisation | `v0.9.1.1` | planned |
138+
| Symbolication for reports | `v0.9.2` | planned |
139+
| Tier 3: DHAT-compatible JSON output | `v0.9.3` | planned |
140+
| `dev-bench` integration (drop dhat) | `v0.9.4` | planned |
141+
| Stable API (`1.0`) | `v1.0.0` | planned |
142+
143+
The `1.0` release freezes the public API and the wire format.
144+
Breaking changes after that require a major bump.
145+
146+
## Out of scope
147+
148+
- Replacing the system allocator. Use `mimalloc` or
149+
`jemallocator` for that.
150+
- Use-after-free / double-free detection. Use AddressSanitizer.
151+
- Source-level instrumentation (build.rs, proc macros). The one
152+
build.rs in this crate exists solely to detect missing frame
153+
pointers at compile time.
92154

93155
## Minimum supported Rust version
94156

95-
`1.75`, pinned in `Cargo.toml` and verified by CI.
157+
`1.75`, pinned in `Cargo.toml` and verified by CI on every push.
96158

97159
## License
98160

@@ -106,4 +168,4 @@ Apache-2.0. See [LICENSE](LICENSE).
106168
<br>
107169
<h2></h2>
108170
Copyright &copy; 2026 James Gober.
109-
</div>
171+
</div>

src/backtrace/table.rs

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -151,23 +151,28 @@ pub(crate) fn record(frames: &Frames, size: u64) {
151151
.compare_exchange(0, h, Ordering::Release, Ordering::Acquire)
152152
{
153153
Ok(_) => {
154-
// We own the initialisation phase. Write
155-
// `sample_frames` first, then the counts,
156-
// then publish via `frame_count` with Release.
154+
// We own the initialisation phase. Use
155+
// `fetch_add` (not `store`) on `count` and
156+
// `total_bytes` so concurrent matching
157+
// writers never have their increments
158+
// clobbered by our initial value. This lets
159+
// the matching path skip `wait_published`
160+
// entirely.
157161
for i in 0..count {
158162
bucket.sample_frames[i].store(frames.frames[i], Ordering::Relaxed);
159163
}
160-
bucket.count.store(1, Ordering::Relaxed);
161-
bucket.total_bytes.store(size, Ordering::Relaxed);
164+
bucket.count.fetch_add(1, Ordering::Relaxed);
165+
bucket.total_bytes.fetch_add(size, Ordering::Relaxed);
162166
bucket.frame_count.store(count as u64, Ordering::Release);
163167
return;
164168
}
165169
Err(observed) => {
166170
if observed == h {
167171
// Same call site, another writer claimed
168-
// first. Wait for them to publish, then
169-
// increment.
170-
wait_published(bucket);
172+
// first. The init thread now uses
173+
// `fetch_add` (not `store`), so our
174+
// increment cannot be clobbered even if
175+
// it lands before init finishes.
171176
bucket.count.fetch_add(1, Ordering::Relaxed);
172177
bucket.total_bytes.fetch_add(size, Ordering::Relaxed);
173178
return;
@@ -177,7 +182,11 @@ pub(crate) fn record(frames: &Frames, size: u64) {
177182
}
178183
}
179184
} else if existing == h {
180-
wait_published(bucket);
185+
// Hot path: same site already in this bucket. No
186+
// wait_published needed because the init thread now
187+
// uses fetch_add for count/total_bytes; whether init
188+
// has finished publishing `sample_frames` is a
189+
// reader (`call_sites_report`) concern, not ours.
181190
bucket.count.fetch_add(1, Ordering::Relaxed);
182191
bucket.total_bytes.fetch_add(size, Ordering::Relaxed);
183192
return;
@@ -191,12 +200,6 @@ pub(crate) fn record(frames: &Frames, size: u64) {
191200
}
192201
}
193202

194-
fn wait_published(bucket: &Bucket) {
195-
while bucket.frame_count.load(Ordering::Acquire) == 0 {
196-
core::hint::spin_loop();
197-
}
198-
}
199-
200203
/// Drain the per-call-site table into a `Vec<CallSiteStats>`.
201204
///
202205
/// Flushes the calling thread's arena first so recent events

src/backtrace/walk.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,15 @@ pub(crate) fn walk(initial_fp: u64, bounds: StackBounds) -> Frames {
7171
// produce a `u64` value treated as bits; we do not
7272
// dereference them further as pointers without the same
7373
// checks repeated on the next iteration.
74-
let new_fp = unsafe { core::ptr::read_volatile(fp as *const u64) };
75-
let return_addr = unsafe { core::ptr::read_volatile((fp + 8) as *const u64) };
74+
//
75+
// Plain reads (not `read_volatile`): the memory we are
76+
// reading is on the calling thread's own stack; no other
77+
// thread mutates it during the walk, so the compiler is
78+
// free to schedule these reads however it likes. The
79+
// `read_volatile` we used previously cost ~50-100 ns per
80+
// walk for no safety benefit.
81+
let new_fp = unsafe { *(fp as *const u64) };
82+
let return_addr = unsafe { *((fp + 8) as *const u64) };
7683

7784
// Monotonicity: chain must progress upward (older frames
7885
// sit at higher addresses on stacks that grow down).

0 commit comments

Comments
 (0)