Repository navigation
v0.9.3 — DHAT-Compatible JSON Output
Pre-releasemod-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 topath. Mirrorsdhat-rs's
dhat-heap.jsonconvention 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.
serdeandserde_jsonare not
pulled in. The hand-rolled writer in
src/dhat_json/writer.rsis ~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.rsbuilds the
ftbland thepps[*].fsindex arrays in one pass over the
report. - Symbolicate-aware in
src/dhat_json/frames.rs: when the
symbolicatefeature is on, the JSON emitter pulls from
symbolicate::symbolicated_report()automatically; otherwise
it pulls frombacktrace::call_sites_report()directly.
Test additions
tests/dhat_json_shape.rs— installsModAlloc, runs a small
workload, asserts the JSON starts and ends with object braces,
contains all required top-level keys, has at least onepps
entry after the workload, and thatwrite_dhat_jsonproduces
the same bytes asdhat_json_string.src/dhat_json/writer.rsunit tests cover JSON escaping for
quote, backslash, control bytes, multibyte UTF-8 pass-through,
and empty strings.src/dhat_json/frames.rsunit tests cover the root entry at
index 0, frame-table dedup correctness, and the raw frame
string format.
Example
examples/dhat_json.rs— installsModAlloc, exercises three
#[inline(never)]workloads of different sizes, prints the
process-wide snapshot, then writesdhat-heap.jsonand
reports the path so the user can open it indh_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-featurescargo build --features counterscargo build --features backtracescargo build --features dhat-compatcargo build --features symbolicatecargo 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: 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 validdhat-heap.jsonwith required
fields presentcargo 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: whenstdis compiled without frame pointers, the
walker captures only the immediate caller ofGlobalAlloc::alloc
insidestd's allocation infrastructure. For meaningful traces
in the JSON, build with
cargo +nightly -Z build-std=stdand
RUSTFLAGS="-C force-frame-pointers=yes". - No per-block max / lifetime columns. With
bklt: falsethe
viewer hides them. Adding lifetime tracking is a v1.x topic — it
requires everydeallocto look up the originating call site,
which is real overhead on the hot path. eb/ebkare always zero. See design note above.tg/teare zero. We do not measure instruction count
or wall time. The viewer treats these as decorative when
bkltis false.
Follow-ups
v0.9.4(dev-benchintegration / drop dhat). Now
unblocked. With v0.9.3,dev-benchcan consumemod-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 adealloc-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 viarustc-demangle. v0.9.1.1Tier 2 perf pass (still open) — separate
milestone, unaffected by this work.
Release ceremony
Standard pattern:
git tag -a v0.9.3 -m "Release v0.9.3 - DHAT-compatible JSON output"git push origin maingit push origin v0.9.3cargo publish --dry-run --all-featurescargo publish- Confirm
https://crates.io/crates/mod-alloc/0.9.3is 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