Skip to content

v0.9.4 — dhat-rs Drop-in Compat Surface

Pre-release
Pre-release

Choose a tag to compare

@jamesgober jamesgober released this 18 May 08:43
· 2 commits to main since this release

mod-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
    matching dhat::Alloc's usage pattern. Forwards every
    GlobalAlloc call to a process-wide static ModAlloc.
  • 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::HeapStats exactly (including the deliberate
    u64 total_* / usize curr_* max_* asymmetry). Constructed
    via HeapStats::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

  • AllocStats gained two fields: live_count: u64 and
    peak_live_count: u64. They back HeapStats::curr_blocks and
    max_blocks. Wired into record_alloc (increment + max) and
    record_dealloc (decrement). record_realloc does not touch
    them — a realloc is one block from a count perspective, which
    matches dhat's accounting.

Storage architecture

  • static INNER: ModAlloc in src/dhat_compat/mod.rs is the
    shared backing store. Every Alloc instance forwards its
    GlobalAlloc calls to INNER, which registers itself into
    GLOBAL_HANDLE on first use. The Profiler and
    HeapStats::get() read from GLOBAL_HANDLE, so the data
    flows transparently.
  • All Alloc forwarder methods are #[inline(always)]. The
    static-dispatch wrapper folds into __rust_alloc's call site,
    matching the call-chain depth of using ModAlloc directly.
  • PROFILER_ACTIVE: AtomicBool as 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_blocks rises 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.
  • 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.rs tests gained
    live_counters_track_alive_blocks and
    record_realloc_does_not_touch_live_count locking 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.json to 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 Drop chains 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-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: 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)
  • cargo run --release --features dhat-compat --example dhat_drop_in:
    writes well-formed dhat-heap.json, HeapStats reports
    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. Use HeapStats::get()
    directly in test assertions until they ship (likely v0.9.5
    if dev-bench needs 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: pps may be
    empty in release builds on Windows when std is not built
    with frame pointers. Counters always work; per-call-site
    tracing requires cargo +nightly -Z build-std=std with
    RUSTFLAGS="-C force-frame-pointers=yes".
  • Drop-time write errors swallowed. Same as dhat-rs.

Follow-ups

  • v0.9.5 — dev-bench swap (consumer-side). The actual
    swap lives in dev-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:

  1. git tag -a v0.9.4 -m "Release v0.9.4 - dhat-rs drop-in compat surface"
  2. git push origin main
  3. git push origin v0.9.4
  4. cargo publish --dry-run --all-features
  5. cargo publish
  6. Confirm https://crates.io/crates/mod-alloc/0.9.4 is 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