diff options
Diffstat (limited to 'src/html/mod.rs')
-rwxr-xr-x | src/html/mod.rs | 837 |
1 files changed, 837 insertions, 0 deletions
diff --git a/src/html/mod.rs b/src/html/mod.rs new file mode 100755 index 0000000..02ff0de --- /dev/null +++ b/src/html/mod.rs @@ -0,0 +1,837 @@ +use crate::report::{make_filename_safe, BenchmarkId, MeasurementData, Report, ReportContext}; +use crate::stats::bivariate::regression::Slope; + +use crate::estimate::Estimate; +use crate::format; +use crate::fs; +use crate::measurement::ValueFormatter; +use crate::plot::{PlotContext, PlotData, Plotter}; +use crate::SavedSample; +use criterion_plot::Size; +use serde::Serialize; +use std::cell::RefCell; +use std::cmp::Ordering; +use std::collections::{BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; +use tinytemplate::TinyTemplate; + +const THUMBNAIL_SIZE: Option<Size> = Some(Size(450, 300)); + +fn debug_context<S: Serialize>(path: &Path, context: &S) { + if crate::debug_enabled() { + let mut context_path = PathBuf::from(path); + context_path.set_extension("json"); + println!("Writing report context to {:?}", context_path); + let result = fs::save(context, &context_path); + if let Err(e) = result { + error!("Failed to write report context debug output: {}", e); + } + } +} + +#[derive(Serialize)] +struct Context { + title: String, + confidence: String, + + thumbnail_width: usize, + thumbnail_height: usize, + + slope: Option<ConfidenceInterval>, + r2: ConfidenceInterval, + mean: ConfidenceInterval, + std_dev: ConfidenceInterval, + median: ConfidenceInterval, + mad: ConfidenceInterval, + throughput: Option<ConfidenceInterval>, + + additional_plots: Vec<Plot>, + + comparison: Option<Comparison>, +} + +#[derive(Serialize)] +struct IndividualBenchmark { + name: String, + path: String, + regression_exists: bool, +} +impl IndividualBenchmark { + fn from_id( + output_directory: &Path, + path_prefix: &str, + id: &BenchmarkId, + ) -> IndividualBenchmark { + let mut regression_path = PathBuf::from(output_directory); + regression_path.push(id.as_directory_name()); + regression_path.push("report"); + regression_path.push("regression.svg"); + + IndividualBenchmark { + name: id.as_title().to_owned(), + path: format!("{}/{}", path_prefix, id.as_directory_name()), + regression_exists: regression_path.is_file(), + } + } +} + +#[derive(Serialize)] +struct SummaryContext { + group_id: String, + + thumbnail_width: usize, + thumbnail_height: usize, + + violin_plot: Option<String>, + line_chart: Option<String>, + + benchmarks: Vec<IndividualBenchmark>, +} + +#[derive(Serialize)] +struct ConfidenceInterval { + lower: String, + upper: String, + point: String, +} + +#[derive(Serialize)] +struct Plot { + name: String, + url: String, +} +impl Plot { + fn new(name: &str, url: &str) -> Plot { + Plot { + name: name.to_owned(), + url: url.to_owned(), + } + } +} + +#[derive(Serialize)] +struct Comparison { + p_value: String, + inequality: String, + significance_level: String, + explanation: String, + + change: ConfidenceInterval, + thrpt_change: Option<ConfidenceInterval>, + additional_plots: Vec<Plot>, +} + +fn if_exists(output_directory: &Path, path: &Path) -> Option<String> { + let report_path = path.join("report/index.html"); + if PathBuf::from(output_directory).join(&report_path).is_file() { + Some(report_path.to_string_lossy().to_string()) + } else { + None + } +} +#[derive(Serialize, Debug)] +struct ReportLink<'a> { + name: &'a str, + path: Option<String>, +} +impl<'a> ReportLink<'a> { + // TODO: Would be nice if I didn't have to keep making these components filename-safe. + fn group(output_directory: &Path, group_id: &'a str) -> ReportLink<'a> { + let path = PathBuf::from(make_filename_safe(group_id)); + + ReportLink { + name: group_id, + path: if_exists(output_directory, &path), + } + } + + fn function(output_directory: &Path, group_id: &str, function_id: &'a str) -> ReportLink<'a> { + let mut path = PathBuf::from(make_filename_safe(group_id)); + path.push(make_filename_safe(function_id)); + + ReportLink { + name: function_id, + path: if_exists(output_directory, &path), + } + } + + fn value(output_directory: &Path, group_id: &str, value_str: &'a str) -> ReportLink<'a> { + let mut path = PathBuf::from(make_filename_safe(group_id)); + path.push(make_filename_safe(value_str)); + + ReportLink { + name: value_str, + path: if_exists(output_directory, &path), + } + } + + fn individual(output_directory: &Path, id: &'a BenchmarkId) -> ReportLink<'a> { + let path = PathBuf::from(id.as_directory_name()); + ReportLink { + name: id.as_title(), + path: if_exists(output_directory, &path), + } + } +} + +#[derive(Serialize)] +struct BenchmarkValueGroup<'a> { + value: Option<ReportLink<'a>>, + benchmarks: Vec<ReportLink<'a>>, +} + +#[derive(Serialize)] +struct BenchmarkGroup<'a> { + group_report: ReportLink<'a>, + + function_ids: Option<Vec<ReportLink<'a>>>, + values: Option<Vec<ReportLink<'a>>>, + + individual_links: Vec<BenchmarkValueGroup<'a>>, +} +impl<'a> BenchmarkGroup<'a> { + fn new(output_directory: &Path, ids: &[&'a BenchmarkId]) -> BenchmarkGroup<'a> { + let group_id = &ids[0].group_id; + let group_report = ReportLink::group(output_directory, group_id); + + let mut function_ids = Vec::with_capacity(ids.len()); + let mut values = Vec::with_capacity(ids.len()); + let mut individual_links = HashMap::with_capacity(ids.len()); + + for id in ids.iter() { + let function_id = id.function_id.as_ref().map(String::as_str); + let value = id.value_str.as_ref().map(String::as_str); + + let individual_link = ReportLink::individual(output_directory, id); + + function_ids.push(function_id); + values.push(value); + + individual_links.insert((function_id, value), individual_link); + } + + fn parse_opt(os: &Option<&str>) -> Option<f64> { + os.and_then(|s| s.parse::<f64>().ok()) + } + + // If all of the value strings can be parsed into a number, sort/dedupe + // numerically. Otherwise sort lexicographically. + if values.iter().all(|os| parse_opt(os).is_some()) { + values.sort_unstable_by(|v1, v2| { + let num1 = parse_opt(&v1); + let num2 = parse_opt(&v2); + + num1.partial_cmp(&num2).unwrap_or(Ordering::Less) + }); + values.dedup_by_key(|os| parse_opt(&os).unwrap()); + } else { + values.sort_unstable(); + values.dedup(); + } + + // Sort and dedupe functions by name. + function_ids.sort_unstable(); + function_ids.dedup(); + + let mut value_groups = Vec::with_capacity(values.len()); + for value in values.iter() { + let row = function_ids + .iter() + .filter_map(|f| individual_links.remove(&(*f, *value))) + .collect::<Vec<_>>(); + value_groups.push(BenchmarkValueGroup { + value: value.map(|s| ReportLink::value(output_directory, group_id, s)), + benchmarks: row, + }); + } + + let function_ids = function_ids + .into_iter() + .map(|os| os.map(|s| ReportLink::function(output_directory, group_id, s))) + .collect::<Option<Vec<_>>>(); + let values = values + .into_iter() + .map(|os| os.map(|s| ReportLink::value(output_directory, group_id, s))) + .collect::<Option<Vec<_>>>(); + + BenchmarkGroup { + group_report, + function_ids, + values, + individual_links: value_groups, + } + } +} + +#[derive(Serialize)] +struct IndexContext<'a> { + groups: Vec<BenchmarkGroup<'a>>, +} + +pub struct Html { + templates: TinyTemplate<'static>, + plotter: RefCell<Box<dyn Plotter>>, +} +impl Html { + pub(crate) fn new(plotter: Box<dyn Plotter>) -> Html { + let mut templates = TinyTemplate::new(); + templates + .add_template("report_link", include_str!("report_link.html.tt")) + .expect("Unable to parse report_link template."); + templates + .add_template("index", include_str!("index.html.tt")) + .expect("Unable to parse index template."); + templates + .add_template("benchmark_report", include_str!("benchmark_report.html.tt")) + .expect("Unable to parse benchmark_report template"); + templates + .add_template("summary_report", include_str!("summary_report.html.tt")) + .expect("Unable to parse summary_report template"); + + let plotter = RefCell::new(plotter); + Html { templates, plotter } + } +} +impl Report for Html { + fn measurement_complete( + &self, + id: &BenchmarkId, + report_context: &ReportContext, + measurements: &MeasurementData<'_>, + formatter: &dyn ValueFormatter, + ) { + try_else_return!({ + let mut report_dir = report_context.output_directory.clone(); + report_dir.push(id.as_directory_name()); + report_dir.push("report"); + fs::mkdirp(&report_dir) + }); + + let typical_estimate = &measurements.absolute_estimates.typical(); + + let time_interval = |est: &Estimate| -> ConfidenceInterval { + ConfidenceInterval { + lower: formatter.format_value(est.confidence_interval.lower_bound), + point: formatter.format_value(est.point_estimate), + upper: formatter.format_value(est.confidence_interval.upper_bound), + } + }; + + let data = measurements.data; + + elapsed! { + "Generating plots", + self.generate_plots(id, report_context, formatter, measurements) + } + + let mut additional_plots = vec![ + Plot::new("Typical", "typical.svg"), + Plot::new("Mean", "mean.svg"), + Plot::new("Std. Dev.", "SD.svg"), + Plot::new("Median", "median.svg"), + Plot::new("MAD", "MAD.svg"), + ]; + if measurements.absolute_estimates.slope.is_some() { + additional_plots.push(Plot::new("Slope", "slope.svg")); + } + + let throughput = measurements + .throughput + .as_ref() + .map(|thr| ConfidenceInterval { + lower: formatter + .format_throughput(thr, typical_estimate.confidence_interval.upper_bound), + upper: formatter + .format_throughput(thr, typical_estimate.confidence_interval.lower_bound), + point: formatter.format_throughput(thr, typical_estimate.point_estimate), + }); + + let context = Context { + title: id.as_title().to_owned(), + confidence: format!( + "{:.2}", + typical_estimate.confidence_interval.confidence_level + ), + + thumbnail_width: THUMBNAIL_SIZE.unwrap().0, + thumbnail_height: THUMBNAIL_SIZE.unwrap().1, + + slope: measurements + .absolute_estimates + .slope + .as_ref() + .map(time_interval), + mean: time_interval(&measurements.absolute_estimates.mean), + median: time_interval(&measurements.absolute_estimates.median), + mad: time_interval(&measurements.absolute_estimates.median_abs_dev), + std_dev: time_interval(&measurements.absolute_estimates.std_dev), + throughput, + + r2: ConfidenceInterval { + lower: format!( + "{:0.7}", + Slope(typical_estimate.confidence_interval.lower_bound).r_squared(&data) + ), + upper: format!( + "{:0.7}", + Slope(typical_estimate.confidence_interval.upper_bound).r_squared(&data) + ), + point: format!( + "{:0.7}", + Slope(typical_estimate.point_estimate).r_squared(&data) + ), + }, + + additional_plots, + + comparison: self.comparison(measurements), + }; + + let mut report_path = report_context.output_directory.clone(); + report_path.push(id.as_directory_name()); + report_path.push("report"); + report_path.push("index.html"); + debug_context(&report_path, &context); + + let text = self + .templates + .render("benchmark_report", &context) + .expect("Failed to render benchmark report template"); + try_else_return!(fs::save_string(&text, &report_path)); + } + + fn summarize( + &self, + context: &ReportContext, + all_ids: &[BenchmarkId], + formatter: &dyn ValueFormatter, + ) { + let all_ids = all_ids + .iter() + .filter(|id| { + let id_dir = context.output_directory.join(id.as_directory_name()); + fs::is_dir(&id_dir) + }) + .collect::<Vec<_>>(); + if all_ids.is_empty() { + return; + } + + let group_id = all_ids[0].group_id.clone(); + + let data = self.load_summary_data(&context.output_directory, &all_ids); + + let mut function_ids = BTreeSet::new(); + let mut value_strs = Vec::with_capacity(all_ids.len()); + for id in all_ids { + if let Some(ref function_id) = id.function_id { + function_ids.insert(function_id); + } + if let Some(ref value_str) = id.value_str { + value_strs.push(value_str); + } + } + + fn try_parse(s: &str) -> Option<f64> { + s.parse::<f64>().ok() + } + + // If all of the value strings can be parsed into a number, sort/dedupe + // numerically. Otherwise sort lexicographically. + if value_strs.iter().all(|os| try_parse(&*os).is_some()) { + value_strs.sort_unstable_by(|v1, v2| { + let num1 = try_parse(&v1); + let num2 = try_parse(&v2); + + num1.partial_cmp(&num2).unwrap_or(Ordering::Less) + }); + value_strs.dedup_by_key(|os| try_parse(&os).unwrap()); + } else { + value_strs.sort_unstable(); + value_strs.dedup(); + } + + for function_id in function_ids { + let samples_with_function: Vec<_> = data + .iter() + .by_ref() + .filter(|&&(ref id, _)| id.function_id.as_ref() == Some(&function_id)) + .collect(); + + if samples_with_function.len() > 1 { + let subgroup_id = + BenchmarkId::new(group_id.clone(), Some(function_id.clone()), None, None); + + self.generate_summary( + &subgroup_id, + &*samples_with_function, + context, + formatter, + false, + ); + } + } + + for value_str in value_strs { + let samples_with_value: Vec<_> = data + .iter() + .by_ref() + .filter(|&&(ref id, _)| id.value_str.as_ref() == Some(&value_str)) + .collect(); + + if samples_with_value.len() > 1 { + let subgroup_id = + BenchmarkId::new(group_id.clone(), None, Some(value_str.clone()), None); + + self.generate_summary( + &subgroup_id, + &*samples_with_value, + context, + formatter, + false, + ); + } + } + + let mut all_data = data.iter().by_ref().collect::<Vec<_>>(); + // First sort the ids/data by value. + // If all of the value strings can be parsed into a number, sort/dedupe + // numerically. Otherwise sort lexicographically. + let all_values_numeric = all_data.iter().all(|(ref id, _)| { + id.value_str + .as_ref() + .map(String::as_str) + .and_then(try_parse) + .is_some() + }); + if all_values_numeric { + all_data.sort_unstable_by(|(a, _), (b, _)| { + let num1 = a.value_str.as_ref().map(String::as_str).and_then(try_parse); + let num2 = b.value_str.as_ref().map(String::as_str).and_then(try_parse); + + num1.partial_cmp(&num2).unwrap_or(Ordering::Less) + }); + } else { + all_data.sort_unstable_by_key(|(id, _)| id.value_str.as_ref()); + } + // Next, sort the ids/data by function name. This results in a sorting priority of + // function name, then value. This one has to be a stable sort. + all_data.sort_by_key(|(id, _)| id.function_id.as_ref()); + + self.generate_summary( + &BenchmarkId::new(group_id, None, None, None), + &*(all_data), + context, + formatter, + true, + ); + self.plotter.borrow_mut().wait(); + } + + fn final_summary(&self, report_context: &ReportContext) { + let output_directory = &report_context.output_directory; + if !fs::is_dir(&output_directory) { + return; + } + + let mut found_ids = try_else_return!(fs::list_existing_benchmarks(&output_directory)); + found_ids.sort_unstable_by_key(|id| id.id().to_owned()); + + // Group IDs by group id + let mut id_groups: HashMap<&str, Vec<&BenchmarkId>> = HashMap::new(); + for id in found_ids.iter() { + id_groups + .entry(&id.group_id) + .or_insert_with(Vec::new) + .push(id); + } + + let mut groups = id_groups + .into_iter() + .map(|(_, group)| BenchmarkGroup::new(output_directory, &group)) + .collect::<Vec<BenchmarkGroup<'_>>>(); + groups.sort_unstable_by_key(|g| g.group_report.name); + + try_else_return!(fs::mkdirp(&output_directory.join("report"))); + + let report_path = output_directory.join("report").join("index.html"); + + let context = IndexContext { groups }; + + debug_context(&report_path, &context); + + let text = self + .templates + .render("index", &context) + .expect("Failed to render index template"); + try_else_return!(fs::save_string(&text, &report_path,)); + } +} +impl Html { + fn comparison(&self, measurements: &MeasurementData<'_>) -> Option<Comparison> { + if let Some(ref comp) = measurements.comparison { + let different_mean = comp.p_value < comp.significance_threshold; + let mean_est = &comp.relative_estimates.mean; + let explanation_str: String; + + if !different_mean { + explanation_str = "No change in performance detected.".to_owned(); + } else { + let comparison = compare_to_threshold(&mean_est, comp.noise_threshold); + match comparison { + ComparisonResult::Improved => { + explanation_str = "Performance has improved.".to_owned(); + } + ComparisonResult::Regressed => { + explanation_str = "Performance has regressed.".to_owned(); + } + ComparisonResult::NonSignificant => { + explanation_str = "Change within noise threshold.".to_owned(); + } + } + } + + let comp = Comparison { + p_value: format!("{:.2}", comp.p_value), + inequality: (if different_mean { "<" } else { ">" }).to_owned(), + significance_level: format!("{:.2}", comp.significance_threshold), + explanation: explanation_str, + + change: ConfidenceInterval { + point: format::change(mean_est.point_estimate, true), + lower: format::change(mean_est.confidence_interval.lower_bound, true), + upper: format::change(mean_est.confidence_interval.upper_bound, true), + }, + + thrpt_change: measurements.throughput.as_ref().map(|_| { + let to_thrpt_estimate = |ratio: f64| 1.0 / (1.0 + ratio) - 1.0; + ConfidenceInterval { + point: format::change(to_thrpt_estimate(mean_est.point_estimate), true), + lower: format::change( + to_thrpt_estimate(mean_est.confidence_interval.lower_bound), + true, + ), + upper: format::change( + to_thrpt_estimate(mean_est.confidence_interval.upper_bound), + true, + ), + } + }), + + additional_plots: vec![ + Plot::new("Change in mean", "change/mean.svg"), + Plot::new("Change in median", "change/median.svg"), + Plot::new("T-Test", "change/t-test.svg"), + ], + }; + Some(comp) + } else { + None + } + } + + fn generate_plots( + &self, + id: &BenchmarkId, + context: &ReportContext, + formatter: &dyn ValueFormatter, + measurements: &MeasurementData<'_>, + ) { + let plot_ctx = PlotContext { + id, + context, + size: None, + is_thumbnail: false, + }; + + let plot_data = PlotData { + measurements, + formatter, + comparison: None, + }; + + let plot_ctx_small = plot_ctx.thumbnail(true).size(THUMBNAIL_SIZE); + + self.plotter.borrow_mut().pdf(plot_ctx, plot_data); + self.plotter.borrow_mut().pdf(plot_ctx_small, plot_data); + if measurements.absolute_estimates.slope.is_some() { + self.plotter.borrow_mut().regression(plot_ctx, plot_data); + self.plotter + .borrow_mut() + .regression(plot_ctx_small, plot_data); + } else { + self.plotter + .borrow_mut() + .iteration_times(plot_ctx, plot_data); + self.plotter + .borrow_mut() + .iteration_times(plot_ctx_small, plot_data); + } + + self.plotter + .borrow_mut() + .abs_distributions(plot_ctx, plot_data); + + if let Some(ref comp) = measurements.comparison { + try_else_return!({ + let mut change_dir = context.output_directory.clone(); + change_dir.push(id.as_directory_name()); + change_dir.push("report"); + change_dir.push("change"); + fs::mkdirp(&change_dir) + }); + + try_else_return!({ + let mut both_dir = context.output_directory.clone(); + both_dir.push(id.as_directory_name()); + both_dir.push("report"); + both_dir.push("both"); + fs::mkdirp(&both_dir) + }); + + let comp_data = plot_data.comparison(&comp); + + self.plotter.borrow_mut().pdf(plot_ctx, comp_data); + self.plotter.borrow_mut().pdf(plot_ctx_small, comp_data); + if measurements.absolute_estimates.slope.is_some() + && comp.base_estimates.slope.is_some() + { + self.plotter.borrow_mut().regression(plot_ctx, comp_data); + self.plotter + .borrow_mut() + .regression(plot_ctx_small, comp_data); + } else { + self.plotter + .borrow_mut() + .iteration_times(plot_ctx, comp_data); + self.plotter + .borrow_mut() + .iteration_times(plot_ctx_small, comp_data); + } + self.plotter.borrow_mut().t_test(plot_ctx, comp_data); + self.plotter + .borrow_mut() + .rel_distributions(plot_ctx, comp_data); + } + + self.plotter.borrow_mut().wait(); + } + + fn load_summary_data<'a>( + &self, + output_directory: &Path, + all_ids: &[&'a BenchmarkId], + ) -> Vec<(&'a BenchmarkId, Vec<f64>)> { + all_ids + .iter() + .filter_map(|id| { + let entry = output_directory.join(id.as_directory_name()).join("new"); + + let SavedSample { iters, times, .. } = + try_else_return!(fs::load(&entry.join("sample.json")), || None); + let avg_times = iters + .into_iter() + .zip(times.into_iter()) + .map(|(iters, time)| time / iters) + .collect::<Vec<_>>(); + + Some((*id, avg_times)) + }) + .collect::<Vec<_>>() + } + + fn generate_summary( + &self, + id: &BenchmarkId, + data: &[&(&BenchmarkId, Vec<f64>)], + report_context: &ReportContext, + formatter: &dyn ValueFormatter, + full_summary: bool, + ) { + let plot_ctx = PlotContext { + id, + context: report_context, + size: None, + is_thumbnail: false, + }; + + try_else_return!( + { + let mut report_dir = report_context.output_directory.clone(); + report_dir.push(id.as_directory_name()); + report_dir.push("report"); + fs::mkdirp(&report_dir) + }, + || {} + ); + + self.plotter.borrow_mut().violin(plot_ctx, formatter, data); + + let value_types: Vec<_> = data.iter().map(|&&(ref id, _)| id.value_type()).collect(); + let mut line_path = None; + + if value_types.iter().all(|x| x == &value_types[0]) { + if let Some(value_type) = value_types[0] { + let values: Vec<_> = data.iter().map(|&&(ref id, _)| id.as_number()).collect(); + if values.iter().any(|x| x != &values[0]) { + self.plotter + .borrow_mut() + .line_comparison(plot_ctx, formatter, data, value_type); + line_path = Some(plot_ctx.line_comparison_path()); + } + } + } + + let path_prefix = if full_summary { "../.." } else { "../../.." }; + let benchmarks = data + .iter() + .map(|&&(ref id, _)| { + IndividualBenchmark::from_id(&report_context.output_directory, path_prefix, id) + }) + .collect(); + + let context = SummaryContext { + group_id: id.as_title().to_owned(), + + thumbnail_width: THUMBNAIL_SIZE.unwrap().0, + thumbnail_height: THUMBNAIL_SIZE.unwrap().1, + + violin_plot: Some(plot_ctx.violin_path().to_string_lossy().into_owned()), + line_chart: line_path.map(|p| p.to_string_lossy().into_owned()), + + benchmarks, + }; + + let mut report_path = report_context.output_directory.clone(); + report_path.push(id.as_directory_name()); + report_path.push("report"); + report_path.push("index.html"); + debug_context(&report_path, &context); + + let text = self + .templates + .render("summary_report", &context) + .expect("Failed to render summary report template"); + try_else_return!(fs::save_string(&text, &report_path,), || {}); + } +} + +enum ComparisonResult { + Improved, + Regressed, + NonSignificant, +} + +fn compare_to_threshold(estimate: &Estimate, noise: f64) -> ComparisonResult { + let ci = &estimate.confidence_interval; + let lb = ci.lower_bound; + let ub = ci.upper_bound; + + if lb < -noise && ub < -noise { + ComparisonResult::Improved + } else if lb > noise && ub > noise { + ComparisonResult::Regressed + } else { + ComparisonResult::NonSignificant + } +} |