mod bitset;
mod chunkvec;
mod collection_config;
mod cvec;
mod diff;
mod diff_reports;
mod dominator;
mod html;
mod id_map;
mod md;
#[cfg(test)]
mod md_test;
mod pass1;
mod pass2;
mod progress;
mod reader;
mod report;
mod retained;
mod rpo_dfs;
mod sweep;
mod trace;
mod types;
mod vbyte;
use std::io::IsTerminal;
use std::{io, process, time::Instant};
use pass1::Pass1;
#[derive(Clone, Copy, PartialEq)]
enum OutputFormat {
Md,
MdGraphs,
Json,
Html,
}
#[derive(Clone)]
pub struct AnalyzeOptions {
pub root_path_max_depth: usize,
pub alloc_sites_top: usize,
pub thread_locals_per_thread: usize,
pub dominator_tree_max_nodes: usize,
pub dominator_tree_max_depth: usize,
pub leak_children_cap: usize,
pub top_consumers: usize,
pub dup_strings: bool,
pub collections: bool,
pub collection_config: Option<std::path::PathBuf>,
pub(crate) coll_descs: Vec<crate::pass2::CollDesc>,
}
#[cfg(test)]
impl Default for AnalyzeOptions {
fn default() -> Self {
DetailLevel::Default.options()
}
}
use clap::{CommandFactory, Parser, Subcommand, ValueEnum, ValueHint};
use clap_complete::Shell;
#[derive(Parser)]
#[command(
name = "hprof-analyzer",
version,
about = "Analyze Java HPROF heap dumps (Eclipse MAT parity)",
long_about = "A fast, low-memory analyzer for Java HPROF heap dumps.\n\n\
Give it a heap dump and it parses the dump in a few streaming passes and \
emits static reports that replicate three Eclipse MAT views: System \
Overview, Leak Suspects, and Top Consumers, plus a Threads overview and \
some extended collection views. Give it a saved Report JSON instead and \
it re-renders that report without re-parsing the dump. Reports render as \
plain Markdown, Markdown with ASCII graphs, self-contained HTML, or \
machine-readable JSON.",
after_help = "EXAMPLES:\n \
hprof-analyzer heap.hprof # Markdown to stdout\n \
hprof-analyzer heap.hprof report.html # HTML (format from .html)\n \
hprof-analyzer heap.hprof report.json # JSON (format from .json)\n \
hprof-analyzer heap.hprof -f md-graphs # Markdown + ASCII graphs\n \
hprof-analyzer report.json report.html # re-render saved JSON to HTML\n \
hprof-analyzer compare reports r1.json r2.json [r3.json …] # cross-dump growth diff\n \
hprof-analyzer completions zsh > _hprof-analyzer # shell completions\n\n\
Install zsh completions:\n \
hprof-analyzer completions zsh > \"${fpath[1]}/_hprof-analyzer\"",
args_conflicts_with_subcommands = true
)]
struct Cli {
#[command(subcommand)]
cmd: Option<Cmd>,
#[arg(value_hint = ValueHint::FilePath)]
input: Option<String>,
#[arg(value_hint = ValueHint::AnyPath)]
output: Option<String>,
#[arg(short, long, value_enum)]
format: Option<FormatArg>,
#[arg(long, value_enum, default_value_t = DetailLevel::Default)]
detail: DetailLevel,
#[arg(short, long)]
verbose: bool,
#[arg(long)]
trace_rss: bool,
#[arg(long, value_enum, default_value_t = ProgressWhen::Auto)]
progress: ProgressWhen,
#[arg(long)]
dup_strings: bool,
#[arg(long)]
collections: bool,
#[arg(long, value_name = "PATH")]
collection_config: Option<std::path::PathBuf>,
}
#[derive(Subcommand)]
enum Cmd {
Compare {
#[command(subcommand)]
cmd: CompareCmd,
},
Completions {
shell: Shell,
},
Dev {
#[command(subcommand)]
cmd: DevCmd,
},
}
#[derive(Subcommand)]
enum CompareCmd {
Mat {
#[arg(value_hint = ValueHint::FilePath)]
mat: String,
#[arg(value_hint = ValueHint::FilePath)]
ours: String,
#[arg(short, long, value_enum)]
format: Option<FormatArg>,
},
Reports {
#[arg(value_hint = ValueHint::FilePath, num_args = 2..)]
reports: Vec<String>,
#[arg(short, long, value_enum)]
format: Option<FormatArg>,
},
}
#[derive(Subcommand)]
enum DevCmd {
EmitSchema,
SweepAggregate {
#[arg(value_hint = ValueHint::DirPath)]
dir: String,
},
DumpPass1 {
#[arg(value_hint = ValueHint::FilePath)]
input: String,
},
}
#[derive(Clone, Copy, PartialEq, ValueEnum)]
enum FormatArg {
Md,
MdGraphs,
Json,
Html,
}
#[derive(Clone, Copy, PartialEq, ValueEnum)]
enum DetailLevel {
Minimal,
Default,
Max,
}
#[derive(Clone, Copy, PartialEq, ValueEnum)]
enum ProgressWhen {
Auto,
Always,
Never,
}
impl DetailLevel {
fn options(self) -> AnalyzeOptions {
let (rd, at, tl, dn, dd, lc, tc) = match self {
DetailLevel::Minimal => (10, 15, 5, 500, 10, 15, 10),
DetailLevel::Default => (30, 50, 20, 5000, 20, 50, 20),
DetailLevel::Max => (200, 500, 100, 100_000, 50, 500, 100),
};
AnalyzeOptions {
root_path_max_depth: rd,
alloc_sites_top: at,
thread_locals_per_thread: tl,
dominator_tree_max_nodes: dn,
dominator_tree_max_depth: dd,
leak_children_cap: lc,
top_consumers: tc,
dup_strings: false,
collections: false,
collection_config: None,
coll_descs: Vec::new(),
}
}
}
impl From<FormatArg> for OutputFormat {
fn from(f: FormatArg) -> Self {
match f {
FormatArg::Md => OutputFormat::Md,
FormatArg::MdGraphs => OutputFormat::MdGraphs,
FormatArg::Json => OutputFormat::Json,
FormatArg::Html => OutputFormat::Html,
}
}
}
fn resolve_format(explicit: Option<FormatArg>, out: Option<&str>) -> OutputFormat {
if let Some(f) = explicit {
return f.into();
}
if let Some(path) = out {
let lower = path.to_ascii_lowercase();
if lower.ends_with(".html") || lower.ends_with(".htm") {
return OutputFormat::Html;
}
if lower.ends_with(".json") || lower.ends_with(".json.gz") {
return OutputFormat::Json;
}
}
OutputFormat::Md
}
fn write_output(path: Option<&str>, text: &str) -> io::Result<()> {
match path {
Some(p) if p.ends_with(".gz") => {
use std::io::Write;
let f = std::fs::File::create(p).map_err(|e| io::Error::new(e.kind(), e))?;
let mut enc = flate2::write::GzEncoder::new(f, flate2::Compression::best());
enc.write_all(text.as_bytes())?;
enc.finish()?;
Ok(())
}
Some(p) => std::fs::write(p, text).map_err(|e| io::Error::new(e.kind(), e)),
None => {
print!("{text}");
Ok(())
}
}
}
fn fail(msg: impl std::fmt::Display) -> ! {
eprintln!("error: {msg}");
process::exit(1);
}
fn main() {
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
let cli = Cli::parse();
match cli.cmd {
None => run_default(cli),
Some(Cmd::Compare { cmd }) => match cmd {
CompareCmd::Mat { mat, ours, format } => {
for p in [&mat, &ours] {
if p != "-" && !std::path::Path::new(p).exists() {
fail(format!("cannot open '{p}': no such file or directory"));
}
}
let json_out = resolve_format(format, None) == OutputFormat::Json;
match diff::run_diff(&mat, &ours, json_out) {
Ok(true) => {}
Ok(false) => process::exit(2),
Err(e) => fail(e),
}
}
CompareCmd::Reports { reports, format } => {
for p in &reports {
if p != "-" && !std::path::Path::new(p).exists() {
fail(format!("cannot open '{p}': no such file or directory"));
}
}
match diff_reports::run(&reports, resolve_format(format, None)) {
Ok(text) => print!("{text}"),
Err(e) => fail(e),
}
}
},
Some(Cmd::Completions { shell }) => {
let mut cmd = Cli::command();
clap_complete::generate(shell, &mut cmd, "hprof-analyzer", &mut io::stdout());
}
Some(Cmd::Dev { cmd }) => match cmd {
DevCmd::EmitSchema => {
let schema = schemars::schema_for!(report::Report);
match serde_json::to_string_pretty(&schema) {
Ok(js) => println!("{js}"),
Err(e) => fail(e),
}
}
DevCmd::SweepAggregate { dir } => match sweep::run_aggregate(&dir) {
Ok(true) => {}
Ok(false) => process::exit(2),
Err(e) => fail(e),
},
DevCmd::DumpPass1 { input } => {
if let Err(e) = dump_pass1_json(&input) {
fail(e);
}
}
},
}
}
fn run_default(cli: Cli) {
let Some(input) = cli.input else {
let mut cmd = Cli::command();
let _ = cmd.write_help(&mut io::stderr());
eprintln!();
process::exit(2);
};
if input_is_hprof(&input) {
if cli.trace_rss {
trace::set_enabled(true);
}
let show_progress = match cli.progress {
ProgressWhen::Always => true,
ProgressWhen::Never => false,
ProgressWhen::Auto => !cli.verbose && !cli.trace_rss && std::io::stderr().is_terminal(),
};
progress::set_enabled(show_progress);
let fmt = resolve_format(cli.format, cli.output.as_deref());
let opts = cli.detail.options();
let opts = AnalyzeOptions {
dup_strings: cli.dup_strings,
collections: cli.collections,
collection_config: cli.collection_config.clone(),
coll_descs: crate::collection_config::load_collection_descs(
cli.collection_config.as_deref(),
),
..opts
};
if let Err(e) = run(
&input,
cli.output.as_deref(),
fmt,
cli.verbose,
cvec::Codec::Zstd3,
opts,
) {
fail(analyze_error_hint(&input, &e));
}
} else {
if cli.collections {
fail(
"--collections has no effect when re-rendering a saved report; \
re-run on the .hprof dump to include it",
);
}
if cli.collection_config.is_some() {
fail(
"--collection-config has no effect when re-rendering a saved report; \
re-run on the .hprof dump to use it",
);
}
if cli.dup_strings {
fail(
"--dup-strings has no effect when re-rendering a saved report; \
re-run on the .hprof dump to include it",
);
}
if cli.detail != DetailLevel::Default {
fail(
"--detail has no effect when re-rendering a saved report; \
re-run on the .hprof dump to change output caps",
);
}
let fmt = resolve_format(cli.format, cli.output.as_deref());
match render_report(&input, fmt) {
Ok(text) => {
if let Err(e) = write_output(cli.output.as_deref(), &text) {
let target = cli.output.as_deref().unwrap_or("<stdout>");
fail(format!("cannot write '{target}': {e}"));
}
}
Err(e) => fail(render_error_hint(&input, &e)),
}
}
}
fn input_is_hprof(input: &str) -> bool {
if input == "-" {
return false;
}
let lower = input.to_ascii_lowercase();
if lower.ends_with(".hprof") || lower.ends_with(".hprof.gz") {
return true;
}
looks_like_hprof(input)
}
fn analyze_error_hint(input: &str, e: &io::Error) -> String {
let msg = e.to_string();
if e.kind() == io::ErrorKind::NotFound && !msg.starts_with("cannot ") {
return format!("cannot open '{input}': no such file or directory");
}
if !looks_like_hprof(input) && std::fs::metadata(input).is_ok() {
return format!(
"{msg}\n(hint: '{input}' does not start with the HPROF magic; if it \
is a saved report JSON, rename it without the .hprof extension to \
re-render it)"
);
}
if e.kind() == io::ErrorKind::UnexpectedEof && looks_like_hprof(input) {
return format!(
"{msg}\n(hint: '{input}' appears truncated or corrupt — the parser \
hit end of file mid-record; re-copy the .hprof dump and retry)"
);
}
msg
}
fn render_error_hint(input: &str, e: &io::Error) -> String {
if e.kind() == io::ErrorKind::NotFound {
return format!("cannot open '{input}': no such file or directory");
}
let msg = e.to_string();
if msg.starts_with("invalid report JSON") && input != "-" {
match sniff_report_kind(input) {
Some("html") => {
return format!(
"{msg}\n(hint: '{input}' looks like a rendered HTML report; \
re-render from the saved report JSON (.json/.json.gz), not \
the .html)"
);
}
Some("markdown") => {
return format!(
"{msg}\n(hint: '{input}' looks like a rendered Markdown report; \
re-render from the saved report JSON (.json/.json.gz))"
);
}
_ => {
return format!(
"{msg}\n(hint: expected a saved report JSON (.json/.json.gz); \
analyze a .hprof dump to produce one)"
);
}
}
}
msg
}
fn sniff_report_kind(path: &str) -> Option<&'static str> {
use std::io::Read;
let mut f = std::fs::File::open(path).ok()?;
let mut head = [0u8; 512];
let n = f.read(&mut head).ok()?;
let head = &head[..n];
let decoded;
let bytes: &[u8] = if head.starts_with(&[0x1f, 0x8b]) {
let mut d = flate2::read::GzDecoder::new(head);
let mut buf = Vec::new();
let _ = d.read_to_end(&mut buf);
if buf.is_empty() {
return None;
}
decoded = buf;
&decoded
} else {
head
};
let s = String::from_utf8_lossy(bytes);
let t = s.trim_start();
let lower = t.to_ascii_lowercase();
if t.starts_with('<') || lower.contains("<!doctype") || lower.contains("<html") {
return Some("html");
}
if t.starts_with('#') || t.starts_with('|') {
return Some("markdown");
}
None
}
fn looks_like_hprof(path: &str) -> bool {
use std::io::Read;
if path == "-" {
return false;
}
let Ok(mut f) = std::fs::File::open(path) else {
return false;
};
let mut head = [0u8; 12];
matches!(f.read_exact(&mut head), Ok(())) && head.starts_with(b"JAVA PROFILE")
}
fn rss_mb() -> f64 {
#[cfg(target_os = "linux")]
{
if let Ok(s) = std::fs::read_to_string("/proc/self/status") {
for line in s.lines() {
if let Some(rest) = line.strip_prefix("VmRSS:") {
let kb: u64 = rest
.split_whitespace()
.next()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
return kb as f64 / 1024.0;
}
}
}
}
0.0
}
fn log(verbose: bool, phase: &str, elapsed: f64) {
if verbose {
let rss = rss_mb();
if rss > 0.0 {
eprintln!("{phase}: {elapsed:.2}s RSS={rss:.0} MB");
} else {
eprintln!("{phase}: {elapsed:.2}s");
}
}
}
fn render_report(path: &str, format: OutputFormat) -> io::Result<String> {
use std::io::Read;
let raw = if path == "-" {
let mut buf = Vec::new();
io::stdin().read_to_end(&mut buf)?;
buf
} else {
std::fs::read(path)?
};
let json = if raw.starts_with(&[0x1f, 0x8b]) {
let mut d = flate2::read::GzDecoder::new(&raw[..]);
let mut s = String::new();
d.read_to_string(&mut s)?;
s
} else {
String::from_utf8(raw).map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData, format!("input not UTF-8: {e}"))
})?
};
let report: report::Report = serde_json::from_str(&json).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid report JSON: {e}"),
)
})?;
if report.schema_version != report::SCHEMA_VERSION {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"report schema_version {} does not match supported version {}; refusing to render",
report.schema_version,
report::SCHEMA_VERSION
),
));
}
Ok(match format {
OutputFormat::Md => report::render_markdown(&report),
OutputFormat::MdGraphs => report::render_markdown_graphs(&report),
OutputFormat::Json => serde_json::to_string_pretty(&report).map_err(io::Error::other)?,
OutputFormat::Html => html::render_html(&report),
})
}
fn run(
input: &str,
output: Option<&str>,
format: OutputFormat,
verbose: bool,
compress: cvec::Codec,
opts: AnalyzeOptions,
) -> io::Result<()> {
let t_total = Instant::now();
let t = Instant::now();
progress::phase("scanning dump (pass 1)");
let p1 = pass1::Pass1::run(input)?;
log(verbose, "pass1", t.elapsed().as_secs_f64());
if p1.class_ids.len() > u32::MAX as usize {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"dump has {} objects, exceeding the {} (u32::MAX) limit of the \
analyzer's index scheme; cannot analyze",
p1.class_ids.len(),
u32::MAX
),
));
}
let t = Instant::now();
progress::phase("building object graph (pass 2)");
let (mut g, mut inbound, shallow_c, class_idx_c, alloc_serial_c) =
pass2::Pass2::build(input, p1, compress, &opts)?;
log(
verbose,
&format!("pass2 n={}", g.n),
t.elapsed().as_secs_f64(),
);
let t = Instant::now();
inbound.compress_id_map(compress)?;
log(verbose, "compress-cold", t.elapsed().as_secs_f64());
crate::trace::probe("main: after compress_id_map (before rpo_dfs)");
let t = Instant::now();
progress::phase("ordering objects (reverse post-order)");
let rpo = rpo_dfs::rpo_dfs(g.n, &g.gc_root_indices, &g.fwd_offsets, &g.fwd_targets);
crate::trace::probe("main: after rpo_dfs (before compress parent_pre)");
log(verbose, "rpo", t.elapsed().as_secs_f64());
crate::trace::trim();
let mut rpo = rpo;
let parent_pre_count = rpo.parent_pre.len();
let parent_pre_c = if compress != cvec::Codec::None {
let c = cvec::CompressedU32::compress(&rpo.parent_pre, compress)?;
rpo.parent_pre = Vec::new();
Some(c)
} else {
None
};
crate::trace::probe("main: after compress parent_pre (before inbound)");
let t = Instant::now();
progress::phase("building inbound references");
let (inb_block_off, inb_data) = inbound.build_from_fwd(
std::mem::take(&mut g.fwd_offsets),
std::mem::take(&mut g.fwd_targets),
&rpo.dfn,
)?;
log(verbose, "inbound", t.elapsed().as_secs_f64());
crate::trace::trim();
let count = parent_pre_count;
rpo.vertex = rpo_dfs::rebuild_vertex(&rpo.dfn, count);
crate::trace::probe("main: after rebuild_vertex (post-inbound, dfn live)");
rpo.dfn = Vec::new();
crate::trace::trim();
if let Some(c) = parent_pre_c {
rpo.parent_pre = c.restore()?;
crate::trace::probe("main: after restore parent_pre (before dominator)");
}
let t = Instant::now();
progress::phase("computing dominators");
g.idom =
dominator::compute_dominators(g.n, rpo, &g.gc_root_indices, &inb_block_off, &inb_data)?;
log(verbose, "dominator", t.elapsed().as_secs_f64());
drop(inb_block_off);
drop(inb_data);
crate::trace::trim();
crate::trace::probe("main: before build_dom_children_csr");
let (dc_off, dc_tgt) = retained::build_dom_children_csr(g.n, &g.idom);
crate::trace::probe("main: after build_dom_children_csr");
if compress != cvec::Codec::None {
g.shallow = shallow_c.restore()?;
g.class_idx = class_idx_c.restore()?;
}
drop(shallow_c);
drop(class_idx_c);
crate::trace::probe("main: after restore shallow/class_idx");
let t = Instant::now();
progress::phase("computing retained sizes");
let class_count = g.class_names.len();
let (retained, has_same, depth_counts) = retained::compute_retained(
g.n,
&g.idom,
&g.shallow,
&g.class_idx,
class_count,
&g.class_obj_class_idx,
&dc_off,
&dc_tgt,
);
g.retained = retained;
g.has_same_class_ancestor = has_same;
log(verbose, "retained", t.elapsed().as_secs_f64());
let alloc_sites = if let Some(c) = alloc_serial_c {
let mut agg = report::AllocAgg::new(&g, opts.alloc_sites_top);
c.for_each_u32(|serial| agg.push(serial))?;
let a = agg.finish();
g.alloc_frames_by_serial = None;
crate::trace::trim();
Some(a)
} else {
let a = report::build_alloc_sites(&g, opts.alloc_sites_top);
g.alloc_stack_serial = Vec::new();
g.alloc_frames_by_serial = None;
Some(a)
};
let t = Instant::now();
progress::phase("building report");
crate::trace::probe("report: before build_model");
let report = report::build_model(
&g,
&dc_off,
&dc_tgt,
opts.leak_children_cap,
&depth_counts,
&opts,
alloc_sites,
);
crate::trace::probe("report: after build_model");
g.has_same_class_ancestor = crate::bitset::Bitset::default(); drop(dc_off);
drop(dc_tgt);
crate::trace::trim();
let out_text = match format {
OutputFormat::Md => {
let md = report::render_markdown(&report);
crate::trace::probe("report: after render_markdown");
md
}
OutputFormat::MdGraphs => {
let md = report::render_markdown_graphs(&report);
crate::trace::probe("report: after render_markdown_graphs");
md
}
OutputFormat::Json => {
let js = serde_json::to_string_pretty(&report).map_err(io::Error::other)?;
crate::trace::probe("report: after serialize_json");
js
}
OutputFormat::Html => {
let h = html::render_html(&report);
crate::trace::probe("report: after render_html");
h
}
};
log(verbose, "report", t.elapsed().as_secs_f64());
progress::done();
write_output(output, &out_text).map_err(|e| {
let target = output.unwrap_or("<stdout>");
io::Error::new(e.kind(), format!("cannot write '{target}': {e}"))
})?;
log(verbose, "total", t_total.elapsed().as_secs_f64());
Ok(())
}
fn dump_pass1_json(path: &str) -> io::Result<()> {
let p = Pass1::run(path)?;
let mut class_hist: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
for (i, &cidx) in p.class_ids.iter().enumerate() {
if p.kind[i] != 0 && p.kind[i] != 3 {
continue;
}
let addr = p.class_addr_table[cidx as usize];
if let Some(ci) = p.class_map.get(&addr) {
let name = p
.strings
.get(&ci.name_id)
.cloned()
.unwrap_or_else(|| format!("unknown@{addr:#x}"));
*class_hist.entry(name).or_insert(0) += 1;
}
}
let mut unique_roots: std::collections::HashSet<u64> = std::collections::HashSet::new();
for &a in &p.gc_root_addrs {
unique_roots.insert(a);
}
print!("{{");
print!(r#""id_size":{}"#, p.id_size);
print!(r#","format":"{}""#, p.format);
print!(r#","instances":{}"#, p.instance_count);
print!(r#","obj_arrays":{}"#, p.obj_array_count);
print!(r#","prim_arrays":{}"#, p.prim_array_count);
print!(r#","classes":{}"#, p.class_dump_count);
print!(r#","gc_roots_total":{}"#, p.gc_root_addrs.len());
print!(r#","strings":{}"#, p.strings.len());
print!(r#","class_histogram":{{"#);
let mut first = true;
for (name, count) in &class_hist {
if !first {
print!(",");
}
let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
print!(r#""{escaped}":{count}"#);
first = false;
}
print!("}}");
println!("}}");
Ok(())
}