Skip to content

v0.9.3 — DHAT-Compatible JSON Output

Pre-release
Pre-release

Choose a tag to compare

@jamesgober jamesgober released this 18 May 08:09
· 5 commits to main since this release

mod-alloc v0.9.3 — DHAT-Compatible JSON Output

Date: 2026-05-18
Compare: v0.9.2...v0.9.3

Headline

The per-call-site report now serialises to the JSON shape the
upstream dh_view.html viewer (shipped with Valgrind) loads
directly. With --features dhat-compat (default OFF), one method
call drops a dhat-heap.json file beside your binary that any
existing DHAT tooling will consume. No new external dependencies;
the JSON writer is hand-rolled. The allocation hot path is
untouched.

What's new

Public API additions

  • ModAlloc::dhat_json_string() -> String — renders the
    report as a DHAT-format JSON string. Allocates; call from
    outside the allocator hook.
  • ModAlloc::write_dhat_json<P: AsRef<Path>>(path) -> io::Result<()>
    — writes the rendered JSON to path. Mirrors dhat-rs's
    dhat-heap.json convention so existing dev tooling that already
    expects that filename works unchanged.

Both methods are behind #[cfg(feature = "dhat-compat")] (which
already implied backtraces). The feature has shipped as a no-op
since v0.9.0; v0.9.3 wires it up.

Output schema

The emitter writes a dhatFileVersion: 2, mode: "rust-heap"
document. Top-level fields:

Field Value
dhatFileVersion 2
mode "rust-heap"
verb "Allocated"
bklt false (we do not track allocation lifetimes)
bkacc false (we do not track per-block accesses)
bu/bsu/bksu "byte"/"bytes"/"blocks"
tu/Mtu/tuth "instrs" / "Minstr" / 0 (decorative under bklt: false)
cmd best-effort argv join via std::env::args_os
pid std::process::id()
tg/te 0 (no time tracking)
pps array of program points; one per call site
ftbl deduplicated frame strings; index 0 is [root]

Each program point in pps carries tb (total bytes), tbk
(count), eb/ebk (zero — we don't snapshot at-end state), and
fs (frame-table indices, top of stack first).

Frame-string format

cfg-split at compile time:

  • Without symbolicate: "0x{addr:x}: <unresolved>"
  • With symbolicate: "0x{addr:x}: {function} ({file}:{line})"
    with [inlined] flagging inlined expansions and graceful
    fallbacks when individual fields are unresolved (e.g.
    ({file}:?) when line info is missing).

Identical strings reused across call sites share one ftbl entry
via a HashMap<String, u32> interner.

Storage architecture

  • No new external deps. serde and serde_json are not
    pulled in. The hand-rolled writer in
    src/dhat_json/writer.rs is ~30 lines and handles the small
    subset of JSON escaping (\\, \", \n, \r, \t, low
    control bytes as \u00XX).
  • Frame interning in src/dhat_json/frames.rs builds the
    ftbl and the pps[*].fs index arrays in one pass over the
    report.
  • Symbolicate-aware in src/dhat_json/frames.rs: when the
    symbolicate feature is on, the JSON emitter pulls from
    symbolicate::symbolicated_report() automatically; otherwise
    it pulls from backtrace::call_sites_report() directly.

Test additions

  • tests/dhat_json_shape.rs — installs ModAlloc, runs a small
    workload, asserts the JSON starts and ends with object braces,
    contains all required top-level keys, has at least one pps
    entry after the workload, and that write_dhat_json produces
    the same bytes as dhat_json_string.
  • src/dhat_json/writer.rs unit tests cover JSON escaping for
    quote, backslash, control bytes, multibyte UTF-8 pass-through,
    and empty strings.
  • src/dhat_json/frames.rs unit tests cover the root entry at
    index 0, frame-table dedup correctness, and the raw frame
    string format.

Example

  • examples/dhat_json.rs — installs ModAlloc, exercises three
    #[inline(never)] workloads of different sizes, prints the
    process-wide snapshot, then writes dhat-heap.json and
    reports the path so the user can open it in dh_view.html.

CI

The existing --all-features build/test steps already cover the
dhat-compat feature, including under the Linux ASAN nightly
job. No new CI step required.

Design notes

Why no serde_json

The schema is fixed (15 top-level keys, 5 keys per program
point) and small. A typical report is 10–100 KB. A hand-rolled
writer is ~50 lines of total code; pulling in serde +
serde_json would add ~30 transitive deps for output we already
fully control. Keeping the dep graph small matters here because
dhat-compat is one of the most likely features for downstream
consumers to enable.

Why bklt: false

We do not track per-allocation lifetimes today. With
bklt: false, the viewer hides the lifetime-related columns
(tl, mb, mbk) and renders only the totals we actually
populate. Setting the flag true while reporting zeros would
mislead the viewer into showing meaningless "every block freed
immediately" rows.

Why eb / ebk are zero

The JSON can be written at any point during execution — there is
no canonical "end" from our perspective. Populating end-state
fields with the current counters would conflate "all time"
totals with "at end" snapshots; emitting zeros matches the
viewer's interpretation of an in-flight profile.

Why frame strings are deduplicated, not the call-site fs path

dh_view.html builds its tree from the ftbl strings, so
deduplicating strings (rather than full stacks) lets the viewer
group call sites that share inner frames without us doing the
tree work ourselves.

Migration

Default builds are unchanged. The counters, backtraces, and
symbolicate features behave identically. No code edits required
for callers on those paths.

Opting in to dhat-compat:

[dependencies]
mod-alloc = { version = "0.9", features = ["dhat-compat"] }
let json = GLOBAL.dhat_json_string();
// or write to disk for the viewer:
GLOBAL.write_dhat_json("dhat-heap.json")?;

To combine with symbolication for resolved function names in the
output:

mod-alloc = { version = "0.9", features = ["symbolicate", "dhat-compat"] }

backtraces is implied by dhat-compat; activating
dhat-compat alone is sufficient for raw-address output, and
adding symbolicate upgrades the frame strings.

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 dhat-compat
  • cargo build --features symbolicate
  • 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: 74/74 pass (43 unit + 4 new
    v0.9.3 integration + 2 v0.9.2 integration + 4 v0.9.1
    integration + 4 v0.9.0 integration + 5 smoke + 13 doctests)
  • cargo run --release --features dhat-compat --example dhat_json:
    produces a syntactically valid dhat-heap.json with required
    fields present
  • cargo run --release --features symbolicate,dhat-compat --example dhat_json:
    symbolicated path emits the same shape with resolved frame
    strings

CI matrix: ubuntu-latest, macos-latest, windows-latest plus the
ASAN nightly Linux job. The --all-features step transitively
exercises dhat-compat; no new CI step required.

Manual viewer verification

A one-time check (not a CI gate): drop dhat-heap.json produced
by the example into the dh_view.html shipped with Valgrind. The
viewer renders the call-site tree using the pps / ftbl
fields. Lifetime columns are hidden as expected under
bklt: false.

Limitations

  • Shallow traces on stock builds. Same situation as v0.9.1 /
    v0.9.2: when std is compiled without frame pointers, the
    walker captures only the immediate caller of GlobalAlloc::alloc
    inside std's allocation infrastructure. For meaningful traces
    in the JSON, build with
    cargo +nightly -Z build-std=std and
    RUSTFLAGS="-C force-frame-pointers=yes".
  • No per-block max / lifetime columns. With bklt: false the
    viewer hides them. Adding lifetime tracking is a v1.x topic — it
    requires every dealloc to look up the originating call site,
    which is real overhead on the hot path.
  • eb / ebk are always zero. See design note above.
  • tg / te are zero. We do not measure instruction count
    or wall time. The viewer treats these as decorative when
    bklt is false.

Follow-ups

  • v0.9.4 (dev-bench integration / drop dhat). Now
    unblocked. With v0.9.3, dev-bench can consume mod-alloc
    output through the same code path it uses for dhat today.
  • Block-lifetime tracking (bklt: true). Open question for
    v1.x: do we add a dealloc-side lookup of originating call
    site? Real per-alloc overhead, but unlocks the lifetime
    columns in the viewer.
  • C++ demangling in frame strings (behind symbolicate).
    Only Rust mangling is decoded today via rustc-demangle.
  • v0.9.1.1 Tier 2 perf pass (still open) — separate
    milestone, unaffected by this work.

Release ceremony

Standard pattern:

  1. git tag -a v0.9.3 -m "Release v0.9.3 - DHAT-compatible JSON output"
  2. git push origin main
  3. git push origin v0.9.3
  4. cargo publish --dry-run --all-features
  5. cargo publish
  6. Confirm https://crates.io/crates/mod-alloc/0.9.3 is live.

GitHub release title: v0.9.3 — DHAT-Compatible JSON Output.
Tag as pre-release; 1.0.0 waits for v0.9.4 (dev-bench
integration) to ship and bake.


Full Changelog: v0.9.2...v0.9.3