v0.9.4 — dhat-rs Drop-in Compat Surface
Pre-releasemod-alloc v0.9.4 — dhat-rs Drop-in Compat Surface
Date: 2026-05-18
Compare: v0.9.3...v0.9.4
Headline
mod-alloc now exposes a dhat_compat module that mirrors
dhat-rs's public surface method-for-method. Consumers can
swap dhat for mod-alloc by changing exactly one line — the
import — and keep their existing global-allocator declaration,
Profiler::new_heap() call, and HeapStats::get() reads
working unchanged. This is the unblock for dev-bench (and
any downstream project pinned to MSRV ≤ 1.84 that today must
choose between dhat's profiling and an older Rust toolchain).
The allocation hot path is unchanged from v0.9.3; the new code
is a thin forwarding wrapper plus two extra atomic counters
to back dhat's curr_blocks / max_blocks fields.
What's new
Public API additions
All gated on the existing dhat-compat cargo feature:
dhat_compat::Alloc— unit-struct global allocator
matchingdhat::Alloc's usage pattern. Forwards every
GlobalAlloccall to a process-wide staticModAlloc.dhat_compat::Profiler— RAII handle that writes a
DHAT JSON report on drop. Methods:new_heap(),
new_ad_hoc(),builder().dhat_compat::ProfilerBuilder— fluent builder. Methods:
ad_hoc(),testing(),file_name(P),
trim_backtraces(Option<usize>),build().dhat_compat::HeapStats— six-field stats struct mirroring
dhat::HeapStatsexactly (including the deliberate
u64 total_*/usize curr_* max_*asymmetry). Constructed
viaHeapStats::get().dhat_compat::AdHocStats+dhat_compat::ad_hoc_event(weight: usize)
— ad-hoc-mode event counters with two-atomic-op tracking.
Public API extensions to existing types
AllocStatsgained two fields:live_count: u64and
peak_live_count: u64. They backHeapStats::curr_blocksand
max_blocks. Wired intorecord_alloc(increment + max) and
record_dealloc(decrement).record_reallocdoes not touch
them — a realloc is one block from a count perspective, which
matches dhat's accounting.
Storage architecture
static INNER: ModAllocinsrc/dhat_compat/mod.rsis the
shared backing store. EveryAllocinstance forwards its
GlobalAlloccalls toINNER, which registers itself into
GLOBAL_HANDLEon first use. TheProfilerand
HeapStats::get()read fromGLOBAL_HANDLE, so the data
flows transparently.- All
Allocforwarder methods are#[inline(always)]. The
static-dispatch wrapper folds into__rust_alloc's call site,
matching the call-chain depth of usingModAllocdirectly. PROFILER_ACTIVE: AtomicBoolas a best-effort single-Profiler
guard. dhat-rs panics on double-construction; we treat it as a
no-op ("last writer wins" on the JSON file) since the panic
surprise hurts more than it helps in downstream test harnesses.
Ad-hoc mode JSON
src/dhat_compat/ad_hoc_writer.rs ships a minimal ad-hoc-mode
JSON writer alongside the v0.9.3 heap-mode writer. Schema:
dhatFileVersion: 2, mode: "ad-hoc", single program point
carrying tb = total_units, tbk = total_events. Loads in
dh_view.html the same way dhat's ad-hoc output does.
Test additions
tests/dhat_compat_surface.rs— 7 end-to-end tests:- Alloc swap pattern compiles and tracks
total_bytes. curr_blocksrises and falls correctly across a workload.Profiler::builder().file_name(p).build()Drop writes the
DHAT JSON file with all required fields..testing()suppresses the drop-time write.ad_hoc_event(w)accumulates both events and units.trim_backtraces(Some(100))accepts oversize values without
panicking.Profiler::new_heap-style construction works cleanly.
- Alloc swap pattern compiles and tracks
- In-tree unit tests in
src/dhat_compat/{mod,profiler,stats,ad_hoc_writer}.rs
cover unit-struct size, builder defaults, ad-hoc counter math,
JSON rendering, and the empty-state shape. src/lib.rstests gained
live_counters_track_alive_blocksand
record_realloc_does_not_touch_live_countlocking the new
counter semantics.
Example
examples/dhat_drop_in.rs— drop-in pattern showing the
one-line import change. Builds against dhat-compat and writes
dhat-heap.jsonto CWD via the Profiler drop.
Migration guide
MIGRATING_FROM_DHAT.md(project root) — side-by-side code
example, full API surface mapping, behavioural-difference
table, rollback steps, and build-flag requirements.
CI
The existing --all-features build/test steps cover the new
module. No new CI step required.
Design notes
Why Alloc is a unit struct, not a type alias
dhat-rs ships pub struct Alloc; and users write
static ALLOC: dhat::Alloc = dhat::Alloc;. For a true drop-in,
we must accept the same syntax — a value-form initializer of
a unit type. A type alias like pub type Alloc = ModAlloc
wouldn't allow the = dhat::Alloc initializer since ModAlloc
isn't a unit struct.
The trade-off: Alloc carries no state, so all counters live
on a separately-stored static INNER: ModAlloc. Every method
forwards through inlined trait dispatch. Net cost: zero;
the inliner folds it away.
Why two new counters in the hot path
HeapStats::curr_blocks and HeapStats::max_blocks were not
derivable from the v0.9.3 counter set. We need to know how many
allocations are currently alive — not just bytes. Adding two
atomics adjacent to the existing current_bytes /
peak_bytes pair costs roughly 3 ns on the alloc hot path
(the new ops dual-issue alongside the existing ones).
Acceptable; preserves drop-in parity.
Why double-Profiler is a no-op (not a panic)
dhat-rs panics on Profiler::new_heap() if one is already
alive. We chose no-op + "last writer wins" because:
- Panicking inside
Dropchains can mask the original error. - Test harnesses sometimes spawn nested profilers
inadvertently; an aborting panic there is hostile. - Real-world misuse is rare and the resulting file overwrite is
recoverable.
Documented in the rustdoc and in MIGRATING_FROM_DHAT.md.
Why trim_backtraces is silently clamped
Our walker captures at most 8 frames per allocation (see
src/backtrace/walk.rs). dhat-rs supports configurable
backtrace depth via its libunwind path. Rather than reject
trim_backtraces(Some(100)) with an error, we accept it and
clamp internally. This keeps the swap mechanical for projects
that ask for 20 / 50 / 100 frames.
Migration
One-line import swap
// Before
use dhat;
// After
use mod_alloc::dhat_compat as dhat;Cargo.toml swap
# Before
[dependencies]
dhat = "0.3"
# After
[dependencies]
mod-alloc = { version = "0.9", features = ["dhat-compat"] }For symbolicated frames in the JSON output (matches dhat-rs's
default richer traces), add symbolicate:
mod-alloc = { version = "0.9", features = ["dhat-compat", "symbolicate"] }See MIGRATING_FROM_DHAT.md for the full surface mapping.
AllocStats field additions (breaking for struct-literal callers)
AllocStats gained live_count and peak_live_count. Code
constructing AllocStats via struct literal needs the new
fields. Code consuming AllocStats via snapshot() or
Profiler::stop() is unaffected.
In-tree update example (from tests/smoke.rs):
// Before
let s = AllocStats {
alloc_count: 5,
total_bytes: 100,
peak_bytes: 80,
current_bytes: 40,
};
// After
let s = AllocStats {
alloc_count: 5,
total_bytes: 100,
peak_bytes: 80,
current_bytes: 40,
live_count: 2,
peak_live_count: 3,
};This is a deliberate 0.x-window break. v1.0 will freeze the
surface; the right time to add fields is now.
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 symbolicatecargo build --features dhat-compatcargo build --all-featurescargo +1.75 build --all-features(MSRV)cargo fmt --all -- --checkcargo clippy --all-targets -- -D warningscargo clippy --all-targets --all-features -- -D warningscargo clippy --all-targets --no-default-features -- -D warningscargo doc --no-depscargo doc --all-features --no-depscargo test --all-features: 97/97 pass (53 unit + 7 new
v0.9.4 integration + 4 v0.9.3 integration + 2 v0.9.2 integration- 4 v0.9.1 integration + 4 v0.9.0 integration + 5 smoke + 19
doctests)
- 4 v0.9.1 integration + 4 v0.9.0 integration + 5 smoke + 19
cargo run --release --features dhat-compat --example dhat_drop_in:
writes well-formeddhat-heap.json,HeapStatsreports
correct counters
CI matrix: ubuntu-latest, macos-latest, windows-latest plus the
ASAN nightly Linux job. The --all-features step covers
dhat-compat; no new CI step required.
Limitations
- dhat assertion macros not yet ported.
dhat::assert!,
dhat::assert_eq!,dhat::assert_ne!require a stored
snapshot-comparator state machine. UseHeapStats::get()
directly in test assertions until they ship (likely v0.9.5
ifdev-benchneeds them). - Backtrace depth fixed at 8. Walker limit from v0.9.1.
trim_backtraces(Some(n > 8))silently clamps. - Shallow traces on stock-std release builds (Windows). Same
v0.9.1-class limitation as previous milestones:ppsmay be
empty inreleasebuilds on Windows whenstdis not built
with frame pointers. Counters always work; per-call-site
tracing requirescargo +nightly -Z build-std=stdwith
RUSTFLAGS="-C force-frame-pointers=yes". - Drop-time write errors swallowed. Same as dhat-rs.
Follow-ups
- v0.9.5 —
dev-benchswap (consumer-side). The actual
swap lives indev-bench's repo. With v0.9.4 shipped, the
swap is mechanical: change one import in dev-bench, run its
test suite, validate report shape, deprecate the dhat path. dhat::assert!family. Port if dev-bench's existing
test suite uses them.- v0.9.6 Tier 2 perf pass. Still open. Separate milestone.
Release ceremony
Standard pattern:
git tag -a v0.9.4 -m "Release v0.9.4 - dhat-rs drop-in compat surface"git push origin maingit push origin v0.9.4cargo publish --dry-run --all-featurescargo publish- Confirm
https://crates.io/crates/mod-alloc/0.9.4is live.
GitHub release title: v0.9.4 — dhat-rs Drop-in Compat Surface.
Tag as pre-release; 1.0.0 waits for dev-bench's actual swap
to bake (the v0.9.5 follow-up).
Full Changelog: v0.9.3...v0.9.4