blob: d06aeb3a04b14b798fed69eb14574ae04a702f26 [file] [edit]
// Copyright 2024 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use flate2::read::GzDecoder;
use handlebars::{Handlebars, handlebars_helper};
use log::debug;
use pprof::Profile as RawProfile;
use prost::Message;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::Path;
use thiserror::Error;
static REPORT_TEMPLATE: &str = include_str!("../templates/report.html.hbs");
#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, Serialize)]
pub struct Sample {
metadata: Metadata,
stack_trace: Vec<Location>,
}
impl Sample {
pub fn parse(profile_bytes: &[u8]) -> Result<Vec<Self>, DupefinderError> {
// pprof profiles are often gzipped; handle both compressed and raw profiles transparently.
let mut decoded_bytes = Vec::new();
let mut decoder = GzDecoder::new(profile_bytes);
let profile_bytes = if decoder.read_to_end(&mut decoded_bytes).is_ok() {
&decoded_bytes
} else {
profile_bytes
};
let raw_profile =
RawProfile::decode(profile_bytes).map_err(DupefinderError::DecodeRawProfileFailed)?;
let functions =
raw_profile.function.iter().map(|f| (f.id, f.clone())).collect::<HashMap<_, _>>();
let mappings =
raw_profile.mapping.iter().map(|m| (m.id, m.clone())).collect::<HashMap<_, _>>();
let mut locations = HashMap::new();
for loc in &raw_profile.location {
let mut lines = vec![];
for line in &loc.line {
let function_id = &(line.function_id as u64);
lines.push(SourceLine {
function: raw_profile.string_table[functions[function_id].name as usize]
.clone(),
filename: raw_profile.string_table[functions[function_id].filename as usize]
.clone(),
line: line.line,
});
}
let library_name = mappings
.get(&loc.mapping_id)
.map(|m| raw_profile.string_table[m.filename as usize].to_owned());
locations.insert(
loc.id,
Location { address: loc.address, library_name: library_name, lines },
);
}
let mut samples = vec![];
for s in &raw_profile.sample {
let mut address = None;
let mut size = None;
let mut timestamp = None;
for label in &s.label {
let key = &raw_profile.string_table[label.key as usize];
match key.as_str() {
"address" => {
address = Some(raw_profile.string_table[label.str as usize].clone())
}
"bytes" => size = Some(label.num),
"timestamp" => timestamp = Some(label.num),
// Ignore any unknown labels to avoid lockstep updates if they don't impact
// the analysis here.
_ => (),
}
}
samples.push(Sample {
metadata: Metadata {
address: address.unwrap(),
size: size.unwrap(),
timestamp: timestamp.unwrap(),
},
stack_trace: s.location_id.iter().map(|id| locations[id].clone()).collect(),
});
}
Ok(samples)
}
fn first_blame_candidate(&self) -> Option<&SourceLine> {
self.stack_trace.iter().flat_map(|l| l.first_blame_candidate()).next()
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct DupesReport {
/// Duplicated bytes per blamed source location.
dupe_locations: Vec<Duplication>,
// NOTE: remaining fields are for serializing to handlebars
dupe_threshold: usize,
timestamp: String,
// allocation counts
num_total: usize,
num_uniques: usize,
num_dupes: usize,
// size tallies
unique_bytes: usize,
duped_bytes: usize,
unblamed_duped_bytes: usize,
total_bytes: usize,
}
impl DupesReport {
pub fn new(
heap_contents: &HashMap<String, Vec<u8>>,
samples: Vec<Sample>,
max_allocs_reported: usize,
dupe_threshold: usize,
timestamp: String,
) -> Result<Self, DupefinderError> {
// get the contents for each symbolized sample
let mut samples_by_contents: HashMap<Vec<u8>, Vec<Sample>> = HashMap::new();
for sample in samples {
let Some(contents) = heap_contents.get(&sample.metadata.address) else {
// This allocation was probably freed after the heap metadata was snapshotted but
// before the address' contents were recorded.
continue;
};
if sample.metadata.size as usize != contents.len() {
return Err(DupefinderError::SizeMismatch {
size_on_disk: contents.len(),
size_in_metadata: sample.metadata.size as usize,
});
}
samples_by_contents.entry(contents.to_owned()).or_default().push(sample);
}
// split allocations into duplicated vs. unique
let mut duped_allocations = vec![];
let mut unique_allocations = vec![];
for (contents, samples) in samples_by_contents {
let alloc = Allocation::new(contents, samples)?;
if alloc.num_copies() > 1 {
duped_allocations.push(alloc);
} else {
unique_allocations.push(alloc);
}
}
// analyze code locations which cause duplication
let mut dupe_sources: HashMap<&SourceLine, usize> = Default::default();
for a in &duped_allocations {
for (count, line) in a.blamed_lines() {
*dupe_sources.entry(line).or_default() += a.single_bytes() * count;
}
}
// store the blamed locations sorted by frequency
let mut dupe_locations = dupe_sources
.into_iter()
.filter(|(_, count)| count >= &dupe_threshold)
.map(|(location, bytes)| Duplication { bytes, source: location.clone() })
.collect::<Vec<_>>();
dupe_locations.sort();
dupe_locations.reverse();
dupe_locations.truncate(max_allocs_reported);
// collect counts to summarize
// one for each unique allocation and one for the initial alloc of each dupe
let num_uniques = unique_allocations.len() + duped_allocations.len();
// don't count the initial allocation for each value in the duplicates
let num_dupes = duped_allocations.iter().map(|a| a.num_copies() - 1).sum();
let num_total = num_uniques + num_dupes;
// tally unique allocations
let no_dupes: usize = unique_allocations.iter().map(|a| a.total_bytes()).sum();
let first_of_each_dupe: usize = duped_allocations.iter().map(|a| a.single_bytes()).sum();
let unique_bytes = no_dupes + first_of_each_dupe;
// don't count the initial allocation for each value in the duplicates
let unblamed_duped_bytes = duped_allocations.iter().map(|a| a.unblamed_bytes()).sum();
let duped_bytes =
duped_allocations.iter().map(|a| a.total_bytes() - a.single_bytes()).sum();
let total_bytes = unique_bytes + duped_bytes;
Ok(Self {
dupe_locations,
dupe_threshold,
timestamp,
num_uniques,
num_dupes,
num_total,
unique_bytes,
duped_bytes,
unblamed_duped_bytes,
total_bytes,
})
}
pub fn dupe_locations(&self) -> &[Duplication] {
&self.dupe_locations
}
pub fn summarize(&self) {
debug!("Summary:");
debug!("# allocations: {}", self.num_total);
debug!("# unique allocations: {}", self.num_uniques);
debug!("# duplicate allocations: {}", self.num_dupes);
debug!("# source locations with duplicates: {}", self.dupe_locations.len());
debug!("Unique allocated bytes: {}", self.unique_bytes);
debug!("Duplicate allocated bytes: {}", self.duped_bytes);
debug!("Total allocation bytes: {}", self.total_bytes);
}
pub fn write_report(&self, f: impl Write) -> Result<(), DupefinderError> {
let mut hbs = Handlebars::new();
hbs.set_strict_mode(true); // surface errors if templates don't render correctly
hbs.set_prevent_indent(true); // nested templates should inherit indentation
hbs.register_template_string("report", REPORT_TEMPLATE)
.map_err(Box::new)
.map_err(DupefinderError::HandlebarsSetupFailed)?;
hbs.register_helper("codesearch_url", Box::new(codesearch_url));
hbs.render_to_write("report", self, f).map_err(DupefinderError::RenderHtmlFailed)?;
Ok(())
}
}
#[derive(Debug, Deserialize, Eq, PartialEq, PartialOrd, Ord, Serialize)]
pub struct Duplication {
bytes: usize,
source: SourceLine,
}
impl Duplication {
pub fn bytes(&self) -> usize {
self.bytes
}
pub fn source(&self) -> &SourceLine {
&self.source
}
}
/// A group of allocations with identical heap contents.
#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, Serialize)]
struct Allocation {
contents: Vec<u8>,
samples: Vec<Sample>,
/// Lines which referenced this duplicated value, with a count for how many times seen.
blame: Vec<(usize, SourceLine)>,
/// Times this value was created by code without an apparent in-tree source reference on the
/// stack.
unblamed: usize,
}
impl Allocation {
fn new(contents: Vec<u8>, samples: Vec<Sample>) -> Result<Self, DupefinderError> {
if samples.is_empty() {
return Err(DupefinderError::NoSamples);
}
let mut blame_counts: HashMap<_, usize> = HashMap::new();
let mut unblamed = 0;
for s in &samples {
if let Some(candidate) = s.first_blame_candidate() {
*blame_counts.entry(candidate).or_default() += 1;
} else {
unblamed += 1;
}
}
let mut blame = blame_counts
.into_iter()
.map(|(location, count)| (count, location.clone()))
.collect::<Vec<_>>();
blame.sort();
blame.reverse();
Ok(Self { contents, samples, blame, unblamed })
}
fn blamed_lines(&self) -> &[(usize, SourceLine)] {
&self.blame
}
fn num_copies(&self) -> usize {
self.samples.len()
}
fn single_bytes(&self) -> usize {
self.contents.len()
}
fn unblamed_bytes(&self) -> usize {
self.unblamed * self.single_bytes()
}
fn total_bytes(&self) -> usize {
self.num_copies() * self.single_bytes()
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, Serialize)]
struct Metadata {
address: String,
size: i64,
timestamp: i64,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, Serialize)]
struct Location {
address: u64,
library_name: Option<String>,
lines: Vec<SourceLine>,
}
impl Location {
fn first_blame_candidate(&self) -> Option<&SourceLine> {
self.lines.iter().filter(|l| l.is_candidate_for_blame()).next()
}
}
#[derive(Clone, Deserialize, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, Serialize)]
pub struct SourceLine {
filename: String,
line: i64,
function: String,
}
impl SourceLine {
pub fn filename(&self) -> &str {
&self.filename
}
pub fn line(&self) -> i64 {
self.line
}
pub fn function(&self) -> &str {
&self.function
}
fn is_candidate_for_blame(&self) -> bool {
// ignore the allocator hook itself
if self.function.contains("__scudo_allocate_hook")
|| self.function.contains("__scudo_realloc_allocate_hook")
|| self.function.contains("_core_rustc_static::hooks::with_profiler_and_call_site")
|| self.function.contains("__sanitizer_fast_backtrace")
|| self.function.contains("zx_libc::sanitizer::fast_backtrace")
{
return false;
}
// ignore external code
if self.filename.starts_with("../../third_party") {
return false;
}
// ignore prebuilts
self.filename.starts_with("../../")
}
}
handlebars_helper!(codesearch_url: |filename: String, line: i64| {
use percent_encoding::{percent_encode, NON_ALPHANUMERIC};
let filename = filename.strip_prefix("../../").unwrap_or(&filename);
format!(
"https://cs.opensource.google/search?q=file:{}&sq=&ss=fuchsia",
percent_encode(format!("{filename}:{line}").as_bytes(), NON_ALPHANUMERIC),
)
});
pub fn read_heap_contents_dir(
path: impl AsRef<Path>,
) -> Result<HashMap<String, Vec<u8>>, std::io::Error> {
let path = path.as_ref();
let mut heap_contents = HashMap::new();
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let address = entry.file_name().to_string_lossy().to_string();
let alloc_contents = std::fs::read(entry.path())?;
heap_contents.insert(address, alloc_contents);
}
Ok(heap_contents)
}
#[derive(Debug, Error)]
pub enum DupefinderError {
#[error("couldn't decode raw profile")]
DecodeRawProfileFailed(#[source] prost::DecodeError),
#[error("profile says {size_in_metadata} bytes but on disk it is {size_on_disk}")]
SizeMismatch { size_on_disk: usize, size_in_metadata: usize },
#[error("profile had no samples")]
NoSamples,
#[error("heap contents didn't include {address}")]
MissingAddress { address: String },
#[error("encountered unknown label on heapdump sample `{unknown}`")]
UnknownSampleLabel { unknown: String },
#[error("couldn't configure handlebars template")]
HandlebarsSetupFailed(#[source] Box<handlebars::TemplateError>),
#[error("couldn't render HTML")]
RenderHtmlFailed(#[source] handlebars::RenderError),
}
#[cfg(test)]
mod tests {
use super::*;
use flate2::Compression;
use flate2::write::GzEncoder;
use pprof::{Label as RawLabel, Sample as RawSample};
#[test]
fn test_gzipped_profile_fallback() {
const FAKE_SAMPLE_ADDRESS: &str = "0x1234";
const FAKE_SAMPLE_SIZE: i64 = 100;
const FAKE_SAMPLE_TIMESTAMP: i64 = 1000;
// Create a minimal profile.
let mut string_table = pprof::StringTableBuilder::default();
let sample = vec![RawSample {
label: vec![
RawLabel {
key: string_table.intern("address"),
str: string_table.intern(FAKE_SAMPLE_ADDRESS),
..Default::default()
},
RawLabel {
key: string_table.intern("bytes"),
num: FAKE_SAMPLE_SIZE,
..Default::default()
},
RawLabel {
key: string_table.intern("timestamp"),
num: FAKE_SAMPLE_TIMESTAMP,
..Default::default()
},
],
..Default::default()
}];
let profile =
RawProfile { string_table: string_table.build(), sample, ..Default::default() };
// Encode to protobuf.
let mut profile_bytes = Vec::new();
profile.encode(&mut profile_bytes).unwrap();
// Compress it.
let mut compressed_bytes = Vec::new();
let mut encoder = GzEncoder::new(&mut compressed_bytes, Compression::default());
encoder.write_all(&profile_bytes).unwrap();
encoder.finish().unwrap();
// Verify that it can be read back from the compressed form.
let samples = Sample::parse(&compressed_bytes).unwrap();
assert_eq!(samples.len(), 1);
assert_eq!(samples[0].metadata.address, FAKE_SAMPLE_ADDRESS);
assert_eq!(samples[0].metadata.size, FAKE_SAMPLE_SIZE);
assert_eq!(samples[0].metadata.timestamp, FAKE_SAMPLE_TIMESTAMP);
// Verify that it can be read back from the uncompressed form too.
let samples = Sample::parse(&profile_bytes).unwrap();
assert_eq!(samples.len(), 1);
assert_eq!(samples[0].metadata.address, FAKE_SAMPLE_ADDRESS);
assert_eq!(samples[0].metadata.size, FAKE_SAMPLE_SIZE);
assert_eq!(samples[0].metadata.timestamp, FAKE_SAMPLE_TIMESTAMP);
}
}