This guide walks through swapping dhat for mod-alloc in a
Rust project. The compatibility surface lives behind the
dhat-compat cargo feature and mirrors dhat-rs's public API
method-for-method, so the migration is typically a one-line
import change.
dhat (the Rust crate) is excellent but its dependency chain
(backtrace 0.3.76 → addr2line 0.25.1) forces consumers onto
MSRV 1.85+. Projects with a broader MSRV target — anything
that needs to support stable Rust from before mid-2025 — pay
a real cost for that.
mod-alloc provides equivalent core profiling with:
- MSRV 1.75. Verified by CI on every push.
- Zero external dependencies on the alloc hot path. Optional
symbolication crates only activate when you opt in to the
symbolicatefeature. - Inline frame-pointer walking for backtrace capture, with
no
backtracecrate dependency.
The trade-off is fewer supported architectures (x86_64 +
aarch64 for Tier 2 capture; other targets still run with
counter-only Tier 1). Most production Rust deploys are one of
the supported two.
Take typical dhat-using code:
use dhat;
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
fn main() {
let _profiler = dhat::Profiler::new_heap();
// ... your work ...
let stats = dhat::HeapStats::get();
println!("total bytes: {}", stats.total_bytes);
}Change exactly one line — the import — and the rest works:
use mod_alloc::dhat_compat as dhat;
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
fn main() {
let _profiler = dhat::Profiler::new_heap();
// ... your work ...
let stats = dhat::HeapStats::get();
println!("total bytes: {}", stats.total_bytes);
}Cargo.toml swaps too:
# Before
[dependencies]
dhat = "0.3"
# After
[dependencies]
mod-alloc = { version = "0.9", features = ["dhat-compat"] }| dhat-rs | mod-alloc dhat_compat |
Notes |
|---|---|---|
dhat::Alloc |
Alloc |
unit struct, same usage |
dhat::Profiler |
Profiler |
RAII; writes JSON on drop |
dhat::Profiler::new_heap() |
Profiler::new_heap() |
identical |
dhat::Profiler::new_ad_hoc() |
Profiler::new_ad_hoc() |
identical |
dhat::Profiler::builder() |
Profiler::builder() |
identical |
dhat::ProfilerBuilder |
ProfilerBuilder |
same builder methods |
.ad_hoc() |
.ad_hoc() |
identical |
.testing() |
.testing() |
identical |
.file_name(p) |
.file_name(p) |
identical |
.trim_backtraces(n) |
.trim_backtraces(n) |
clamped to walker cap (8) |
.build() |
.build() |
identical |
dhat::HeapStats |
HeapStats |
identical fields |
dhat::HeapStats::get() |
HeapStats::get() |
identical |
dhat::AdHocStats |
AdHocStats |
identical fields |
dhat::AdHocStats::get() |
AdHocStats::get() |
identical |
dhat::ad_hoc_event(w) |
ad_hoc_event(w) |
identical |
dhat::assert! |
not yet shipped | use HeapStats::get() directly |
dhat::assert_eq! |
not yet shipped | use HeapStats::get() directly |
dhat::assert_ne! |
not yet shipped | use HeapStats::get() directly |
The three assertion macros are the only intentional gap. They
require a stored snapshot comparator that hasn't been ported yet
— if you need them, file an issue or use HeapStats::get() in
your own assertions.
These are documented divergences. None affect the common profiling workflow.
dhat uses the backtrace crate which captures the full call
stack (potentially hundreds of frames). mod-alloc's inline
frame-pointer walker captures up to 8 frames per allocation.
ProfilerBuilder::trim_backtraces(Some(n)) is accepted for
API parity but silently clamped:
Some(n)wheren <= 8produces up tonframesSome(n)wheren > 8produces up to 8 framesNoneproduces up to 8 frames (the default)
For most call-site grouping, 8 frames is more than enough; the
upstream dh_view.html viewer collapses identical inner stacks
regardless.
dhat-rs swallows IO errors from Profiler::drop silently
(Drop can't propagate ?). mod-alloc does the same. If you
need error visibility, write the JSON explicitly via
ModAlloc::write_dhat_json from the underlying allocator
type before the Profiler drops.
dhat-rs panics if more than one Profiler is alive at a time.
mod-alloc treats the second construction as a no-op and
reports "last writer wins" on the JSON file. Real-world code
rarely hits this, and the no-op behaviour is friendlier in test
harnesses.
Both crates emit dhatFileVersion: 2 documents that the
upstream dh_view.html viewer loads. Field ordering,
float-formatting details, and certain decorative fields
(tg, te, etc.) may differ between the two. The viewer
doesn't care; downstream tooling that does byte-level
comparison may.
mod-alloc emits 0x{addr:x}: <unresolved> without the
symbolicate feature, or 0x{addr:x}: <function> ({file}:{line})
with it. dhat emits richer strings out of the box thanks to its
mandatory backtrace/addr2line deps. Enable symbolicate
to match dhat's resolution quality:
mod-alloc = { version = "0.9", features = ["dhat-compat", "symbolicate"] }mod-alloc Tier 2 capture requires frame pointers. Add to your
project's .cargo/config.toml:
[build]
rustflags = ["-C", "force-frame-pointers=yes"]The crate's build.rs emits a cargo:warning= at compile time
if RUSTFLAGS is missing this. Without it, the walker degrades
gracefully (shallow traces) but doesn't crash.
For maximally informative traces, rebuild std with frame
pointers:
RUSTFLAGS="-C force-frame-pointers=yes" \
cargo +nightly -Z build-std=std testdhat uses libunwind and doesn't need this flag — but you also inherit dhat's MSRV 1.85+ requirement. Pick your trade.
Identical to dhat-rs: open the produced dhat-heap.json in
dh_view.html (shipped with Valgrind). The viewer renders both
crates' output the same way.
The dhat_compat module is purely additive. Reverting:
- Change the import back:
use dhat; - Swap the dep:
dhat = "0.3"instead ofmod-alloc = { ... features = ["dhat-compat"] } - Rebuild.
No public API in your own code changes.
If you hit a dhat feature mod-alloc doesn't cover, file an issue at https://github.com/jamesgober/mod-alloc/issues. The compatibility surface is a living target — if dev-bench or your project needs an additional dhat method ported, it gets prioritised.