// This file is part of Substrate. // Copyright (C) Parity Technologies (UK) Ltd. // SPDX-License-Identifier: Apache-2.0 // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Tools for analyzing the benchmark results. use crate::BenchmarkResult; use std::collections::BTreeMap; pub struct Analysis { pub base: u128, pub slopes: Vec, pub names: Vec, pub value_dists: Option, u128, u128)>>, pub errors: Option>, pub minimum: u128, selector: BenchmarkSelector, } #[derive(Clone, Copy)] pub enum BenchmarkSelector { ExtrinsicTime, StorageRootTime, Reads, Writes, ProofSize, } /// Multiplies the value by 1000 and converts it into an u128. fn mul_1000_into_u128(value: f64) -> u128 { // This is slighly more precise than the alternative of `(value * 1000.0) as u128`. (value as u128) .saturating_mul(1000) .saturating_add((value.fract() * 1000.0) as u128) } impl BenchmarkSelector { fn scale_and_cast_weight(self, value: f64, round_up: bool) -> u128 { if let BenchmarkSelector::ExtrinsicTime = self { // We add a very slight bias here to counteract the numerical imprecision of the linear // regression where due to rounding issues it can emit a number like `2999999.999999998` // which we most certainly always want to round up instead of truncating. mul_1000_into_u128(value + 0.000_000_005) } else { if round_up { (value + 0.5) as u128 } else { value as u128 } } } fn scale_weight(self, value: u128) -> u128 { if let BenchmarkSelector::ExtrinsicTime = self { value.saturating_mul(1000) } else { value } } fn nanos_from_weight(self, value: u128) -> u128 { if let BenchmarkSelector::ExtrinsicTime = self { value / 1000 } else { value } } fn get_value(self, result: &BenchmarkResult) -> u128 { match self { BenchmarkSelector::ExtrinsicTime => result.extrinsic_time, BenchmarkSelector::StorageRootTime => result.storage_root_time, BenchmarkSelector::Reads => result.reads.into(), BenchmarkSelector::Writes => result.writes.into(), BenchmarkSelector::ProofSize => result.proof_size.into(), } } fn get_minimum(self, results: &[BenchmarkResult]) -> u128 { results .iter() .map(|result| self.get_value(result)) .min() .expect("results cannot be empty") } } #[derive(Debug)] pub enum AnalysisChoice { /// Use minimum squares regression for analyzing the benchmarking results. MinSquares, /// Use median slopes for analyzing the benchmarking results. MedianSlopes, /// Use the maximum values among all other analysis functions for the benchmarking results. Max, } impl Default for AnalysisChoice { fn default() -> Self { AnalysisChoice::MinSquares } } impl TryFrom> for AnalysisChoice { type Error = &'static str; fn try_from(s: Option) -> Result { match s { None => Ok(AnalysisChoice::default()), Some(i) => match &i[..] { "min-squares" | "min_squares" => Ok(AnalysisChoice::MinSquares), "median-slopes" | "median_slopes" => Ok(AnalysisChoice::MedianSlopes), "max" => Ok(AnalysisChoice::Max), _ => Err("invalid analysis string"), }, } } } fn raw_linear_regression( xs: &[f64], ys: &[f64], x_vars: usize, with_intercept: bool, ) -> Option<(f64, Vec, Vec)> { let mut data: Vec = Vec::new(); // Here we build a raw matrix of linear equations for the `linregress` crate to solve for us // and build a linear regression model around it. // // Each row of the matrix contains as the first column the actual value which we want // the model to predict for us (the `y`), and the rest of the columns contain the input // parameters on which the model will base its predictions on (the `xs`). // // In machine learning terms this is essentially the training data for the model. // // As a special case the very first input parameter represents the constant factor // of the linear equation: the so called "intercept value". Since it's supposed to // be constant we can just put a dummy input parameter of either a `1` (in case we want it) // or a `0` (in case we do not). for (&y, xs) in ys.iter().zip(xs.chunks_exact(x_vars)) { data.push(y); if with_intercept { data.push(1.0); } else { data.push(0.0); } data.extend(xs); } let model = linregress::fit_low_level_regression_model(&data, ys.len(), x_vars + 2).ok()?; Some((model.parameters()[0], model.parameters()[1..].to_vec(), model.se().to_vec())) } fn linear_regression( xs: Vec, mut ys: Vec, x_vars: usize, ) -> Option<(f64, Vec, Vec)> { let (intercept, params, errors) = raw_linear_regression(&xs, &ys, x_vars, true)?; if intercept >= -0.0001 { // The intercept is positive, or is effectively zero. return Some((intercept, params, errors[1..].to_vec())) } // The intercept is negative. // The weights must be always positive, so we can't have that. let mut min = ys[0]; for &value in &ys { if value < min { min = value; } } for value in &mut ys { *value -= min; } let (intercept, params, errors) = raw_linear_regression(&xs, &ys, x_vars, false)?; assert!(intercept.abs() <= 0.0001); Some((min, params, errors[1..].to_vec())) } impl Analysis { // Useful for when there are no components, and we just need an median value of the benchmark // results. Note: We choose the median value because it is more robust to outliers. fn median_value(r: &Vec, selector: BenchmarkSelector) -> Option { if r.is_empty() { return None } let mut values: Vec = r .iter() .map(|result| match selector { BenchmarkSelector::ExtrinsicTime => result.extrinsic_time, BenchmarkSelector::StorageRootTime => result.storage_root_time, BenchmarkSelector::Reads => result.reads.into(), BenchmarkSelector::Writes => result.writes.into(), BenchmarkSelector::ProofSize => result.proof_size.into(), }) .collect(); values.sort(); let mid = values.len() / 2; Some(Self { base: selector.scale_weight(values[mid]), slopes: Vec::new(), names: Vec::new(), value_dists: None, errors: None, minimum: selector.get_minimum(&r), selector, }) } pub fn median_slopes(r: &Vec, selector: BenchmarkSelector) -> Option { if r[0].components.is_empty() { return Self::median_value(r, selector) } let results = r[0] .components .iter() .enumerate() .map(|(i, &(param, _))| { let mut counted = BTreeMap::, usize>::new(); for result in r.iter() { let mut p = result.components.iter().map(|x| x.1).collect::>(); p[i] = 0; *counted.entry(p).or_default() += 1; } let others: Vec = counted.iter().max_by_key(|i| i.1).expect("r is not empty; qed").0.clone(); let values = r .iter() .filter(|v| { v.components .iter() .map(|x| x.1) .zip(others.iter()) .enumerate() .all(|(j, (v1, v2))| j == i || v1 == *v2) }) .map(|result| { // Extract the data we are interested in analyzing let data = match selector { BenchmarkSelector::ExtrinsicTime => result.extrinsic_time, BenchmarkSelector::StorageRootTime => result.storage_root_time, BenchmarkSelector::Reads => result.reads.into(), BenchmarkSelector::Writes => result.writes.into(), BenchmarkSelector::ProofSize => result.proof_size.into(), }; (result.components[i].1, data) }) .collect::>(); (format!("{:?}", param), i, others, values) }) .collect::>(); let models = results .iter() .map(|(_, _, _, ref values)| { let mut slopes = vec![]; for (i, &(x1, y1)) in values.iter().enumerate() { for &(x2, y2) in values.iter().skip(i + 1) { if x1 != x2 { slopes.push((y1 as f64 - y2 as f64) / (x1 as f64 - x2 as f64)); } } } slopes.sort_by(|a, b| a.partial_cmp(b).expect("values well defined; qed")); let slope = slopes[slopes.len() / 2]; let mut offsets = vec![]; for &(x, y) in values.iter() { offsets.push(y as f64 - slope * x as f64); } offsets.sort_by(|a, b| a.partial_cmp(b).expect("values well defined; qed")); let offset = offsets[offsets.len() / 2]; (offset, slope) }) .collect::>(); let models = models .iter() .zip(results.iter()) .map(|((offset, slope), (_, i, others, _))| { let over = others .iter() .enumerate() .filter(|(j, _)| j != i) .map(|(j, v)| models[j].1 * *v as f64) .fold(0f64, |acc, i| acc + i); (*offset - over, *slope) }) .collect::>(); let base = selector.scale_and_cast_weight(models[0].0.max(0f64), false); let slopes = models .iter() .map(|x| selector.scale_and_cast_weight(x.1.max(0f64), false)) .collect::>(); Some(Self { base, slopes, names: results.into_iter().map(|x| x.0).collect::>(), value_dists: None, errors: None, minimum: selector.get_minimum(&r), selector, }) } pub fn min_squares_iqr(r: &Vec, selector: BenchmarkSelector) -> Option { if r[0].components.is_empty() || r.len() <= 2 { return Self::median_value(r, selector) } let mut results = BTreeMap::, Vec>::new(); for result in r.iter() { let p = result.components.iter().map(|x| x.1).collect::>(); results.entry(p).or_default().push(match selector { BenchmarkSelector::ExtrinsicTime => result.extrinsic_time, BenchmarkSelector::StorageRootTime => result.storage_root_time, BenchmarkSelector::Reads => result.reads.into(), BenchmarkSelector::Writes => result.writes.into(), BenchmarkSelector::ProofSize => result.proof_size.into(), }) } for (_, rs) in results.iter_mut() { rs.sort(); let ql = rs.len() / 4; *rs = rs[ql..rs.len() - ql].to_vec(); } let names = r[0].components.iter().map(|x| format!("{:?}", x.0)).collect::>(); let value_dists = results .iter() .map(|(p, vs)| { // Avoid divide by zero if vs.is_empty() { return (p.clone(), 0, 0) } let total = vs.iter().fold(0u128, |acc, v| acc + *v); let mean = total / vs.len() as u128; let sum_sq_diff = vs.iter().fold(0u128, |acc, v| { let d = mean.max(*v) - mean.min(*v); acc + d * d }); let stddev = (sum_sq_diff as f64 / vs.len() as f64).sqrt() as u128; (p.clone(), mean, stddev) }) .collect::>(); let mut ys: Vec = Vec::new(); let mut xs: Vec = Vec::new(); for result in results { let x: Vec = result.0.iter().map(|value| *value as f64).collect(); for y in result.1 { xs.extend(x.iter().copied()); ys.push(y as f64); } } let (intercept, slopes, errors) = linear_regression(xs, ys, r[0].components.len())?; Some(Self { base: selector.scale_and_cast_weight(intercept, true), slopes: slopes .into_iter() .map(|value| selector.scale_and_cast_weight(value, true)) .collect(), names, value_dists: Some(value_dists), errors: Some( errors .into_iter() .map(|value| selector.scale_and_cast_weight(value, false)) .collect(), ), minimum: selector.get_minimum(&r), selector, }) } pub fn max(r: &Vec, selector: BenchmarkSelector) -> Option { let median_slopes = Self::median_slopes(r, selector); let min_squares = Self::min_squares_iqr(r, selector); if median_slopes.is_none() || min_squares.is_none() { return None } let median_slopes = median_slopes.unwrap(); let min_squares = min_squares.unwrap(); let base = median_slopes.base.max(min_squares.base); let slopes = median_slopes .slopes .into_iter() .zip(min_squares.slopes.into_iter()) .map(|(a, b): (u128, u128)| a.max(b)) .collect::>(); // components should always be in the same order median_slopes .names .iter() .zip(min_squares.names.iter()) .for_each(|(a, b)| assert!(a == b, "benchmark results not in the same order")); let names = median_slopes.names; let value_dists = min_squares.value_dists; let errors = min_squares.errors; let minimum = selector.get_minimum(&r); Some(Self { base, slopes, names, value_dists, errors, selector, minimum }) } } fn ms(mut nanos: u128) -> String { let mut x = 100_000u128; while x > 1 { if nanos > x * 1_000 { nanos = nanos / x * x; break } x /= 10; } format!("{}", nanos as f64 / 1_000f64) } impl std::fmt::Display for Analysis { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { if let Some(ref value_dists) = self.value_dists { writeln!(f, "\nData points distribution:")?; writeln!( f, "{} mean µs sigma µs %", self.names.iter().map(|p| format!("{:>5}", p)).collect::>().join(" ") )?; for (param_values, mean, sigma) in value_dists.iter() { if *mean == 0 { writeln!( f, "{} {:>8} {:>8} {:>3}.{}%", param_values .iter() .map(|v| format!("{:>5}", v)) .collect::>() .join(" "), ms(*mean), ms(*sigma), "?", "?" )?; } else { writeln!( f, "{} {:>8} {:>8} {:>3}.{}%", param_values .iter() .map(|v| format!("{:>5}", v)) .collect::>() .join(" "), ms(*mean), ms(*sigma), (sigma * 100 / mean), (sigma * 1000 / mean % 10) )?; } } } if let Some(ref errors) = self.errors { writeln!(f, "\nQuality and confidence:")?; writeln!(f, "param error")?; for (p, se) in self.names.iter().zip(errors.iter()) { writeln!(f, "{} {:>8}", p, ms(self.selector.nanos_from_weight(*se)))?; } } writeln!(f, "\nModel:")?; writeln!(f, "Time ~= {:>8}", ms(self.selector.nanos_from_weight(self.base)))?; for (&t, n) in self.slopes.iter().zip(self.names.iter()) { writeln!(f, " + {} {:>8}", n, ms(self.selector.nanos_from_weight(t)))?; } writeln!(f, " µs") } } impl std::fmt::Debug for Analysis { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{}", self.base)?; for (&m, n) in self.slopes.iter().zip(self.names.iter()) { write!(f, " + ({} * {})", m, n)?; } write!(f, "") } } #[cfg(test)] mod tests { use super::*; use crate::BenchmarkParameter; fn benchmark_result( components: Vec<(BenchmarkParameter, u32)>, extrinsic_time: u128, storage_root_time: u128, reads: u32, writes: u32, ) -> BenchmarkResult { BenchmarkResult { components, extrinsic_time, storage_root_time, reads, repeat_reads: 0, writes, repeat_writes: 0, proof_size: 0, keys: vec![], } } #[test] fn test_linear_regression() { let ys = vec![ 3797981.0, 37857779.0, 70569402.0, 104004114.0, 137233924.0, 169826237.0, 203521133.0, 237552333.0, 271082065.0, 305554637.0, 335218347.0, 371759065.0, 405086197.0, 438353555.0, 472891417.0, 505339532.0, 527784778.0, 562590596.0, 635291991.0, 673027090.0, 708119408.0, ]; let xs = vec![ 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, ]; let (intercept, params, errors) = raw_linear_regression(&xs, &ys, 1, true).unwrap(); assert_eq!(intercept as i64, -2712997); assert_eq!(params.len(), 1); assert_eq!(params[0] as i64, 34444926); assert_eq!(errors.len(), 2); assert_eq!(errors[0] as i64, 4805766); assert_eq!(errors[1] as i64, 411084); let (intercept, params, errors) = linear_regression(xs, ys, 1).unwrap(); assert_eq!(intercept as i64, 3797981); assert_eq!(params.len(), 1); assert_eq!(params[0] as i64, 33968513); assert_eq!(errors.len(), 1); assert_eq!(errors[0] as i64, 217331); } #[test] fn analysis_median_slopes_should_work() { let data = vec![ benchmark_result( vec![(BenchmarkParameter::n, 1), (BenchmarkParameter::m, 5)], 11_500_000, 0, 3, 10, ), benchmark_result( vec![(BenchmarkParameter::n, 2), (BenchmarkParameter::m, 5)], 12_500_000, 0, 4, 10, ), benchmark_result( vec![(BenchmarkParameter::n, 3), (BenchmarkParameter::m, 5)], 13_500_000, 0, 5, 10, ), benchmark_result( vec![(BenchmarkParameter::n, 4), (BenchmarkParameter::m, 5)], 14_500_000, 0, 6, 10, ), benchmark_result( vec![(BenchmarkParameter::n, 3), (BenchmarkParameter::m, 1)], 13_100_000, 0, 5, 2, ), benchmark_result( vec![(BenchmarkParameter::n, 3), (BenchmarkParameter::m, 3)], 13_300_000, 0, 5, 6, ), benchmark_result( vec![(BenchmarkParameter::n, 3), (BenchmarkParameter::m, 7)], 13_700_000, 0, 5, 14, ), benchmark_result( vec![(BenchmarkParameter::n, 3), (BenchmarkParameter::m, 10)], 14_000_000, 0, 5, 20, ), ]; let extrinsic_time = Analysis::median_slopes(&data, BenchmarkSelector::ExtrinsicTime).unwrap(); assert_eq!(extrinsic_time.base, 10_000_000_000); assert_eq!(extrinsic_time.slopes, vec![1_000_000_000, 100_000_000]); let reads = Analysis::median_slopes(&data, BenchmarkSelector::Reads).unwrap(); assert_eq!(reads.base, 2); assert_eq!(reads.slopes, vec![1, 0]); let writes = Analysis::median_slopes(&data, BenchmarkSelector::Writes).unwrap(); assert_eq!(writes.base, 0); assert_eq!(writes.slopes, vec![0, 2]); } #[test] fn analysis_median_min_squares_should_work() { let data = vec![ benchmark_result( vec![(BenchmarkParameter::n, 1), (BenchmarkParameter::m, 5)], 11_500_000, 0, 3, 10, ), benchmark_result( vec![(BenchmarkParameter::n, 2), (BenchmarkParameter::m, 5)], 12_500_000, 0, 4, 10, ), benchmark_result( vec![(BenchmarkParameter::n, 3), (BenchmarkParameter::m, 5)], 13_500_000, 0, 5, 10, ), benchmark_result( vec![(BenchmarkParameter::n, 4), (BenchmarkParameter::m, 5)], 14_500_000, 0, 6, 10, ), benchmark_result( vec![(BenchmarkParameter::n, 3), (BenchmarkParameter::m, 1)], 13_100_000, 0, 5, 2, ), benchmark_result( vec![(BenchmarkParameter::n, 3), (BenchmarkParameter::m, 3)], 13_300_000, 0, 5, 6, ), benchmark_result( vec![(BenchmarkParameter::n, 3), (BenchmarkParameter::m, 7)], 13_700_000, 0, 5, 14, ), benchmark_result( vec![(BenchmarkParameter::n, 3), (BenchmarkParameter::m, 10)], 14_000_000, 0, 5, 20, ), ]; let extrinsic_time = Analysis::min_squares_iqr(&data, BenchmarkSelector::ExtrinsicTime).unwrap(); assert_eq!(extrinsic_time.base, 10_000_000_000); assert_eq!(extrinsic_time.slopes, vec![1000000000, 100000000]); let reads = Analysis::min_squares_iqr(&data, BenchmarkSelector::Reads).unwrap(); assert_eq!(reads.base, 2); assert_eq!(reads.slopes, vec![1, 0]); let writes = Analysis::min_squares_iqr(&data, BenchmarkSelector::Writes).unwrap(); assert_eq!(writes.base, 0); assert_eq!(writes.slopes, vec![0, 2]); } #[test] fn analysis_min_squares_iqr_uses_multiple_samples_for_same_parameters() { let data = vec![ benchmark_result(vec![(BenchmarkParameter::n, 0)], 2_000_000, 0, 0, 0), benchmark_result(vec![(BenchmarkParameter::n, 0)], 4_000_000, 0, 0, 0), benchmark_result(vec![(BenchmarkParameter::n, 1)], 4_000_000, 0, 0, 0), benchmark_result(vec![(BenchmarkParameter::n, 1)], 8_000_000, 0, 0, 0), ]; let extrinsic_time = Analysis::min_squares_iqr(&data, BenchmarkSelector::ExtrinsicTime).unwrap(); assert_eq!(extrinsic_time.base, 3_000_000_000); assert_eq!(extrinsic_time.slopes, vec![3_000_000_000]); } #[test] fn intercept_of_a_little_under_zero_is_rounded_up_to_zero() { // Analytically this should result in an intercept of 0, but // due to numerical imprecision this will generate an intercept // equal to roughly -0.0000000000000004440892098500626 let data = vec![ benchmark_result(vec![(BenchmarkParameter::n, 1)], 2, 0, 0, 0), benchmark_result(vec![(BenchmarkParameter::n, 2)], 4, 0, 0, 0), benchmark_result(vec![(BenchmarkParameter::n, 3)], 6, 0, 0, 0), ]; let extrinsic_time = Analysis::min_squares_iqr(&data, BenchmarkSelector::ExtrinsicTime).unwrap(); assert_eq!(extrinsic_time.base, 0); assert_eq!(extrinsic_time.slopes, vec![2000]); } }