// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading; using System.Xml; using Perfolizer.Mathematics.Multimodality; using Perfolizer.Mathematics.SignificanceTesting; using Perfolizer.Mathematics.Thresholds; using CommandLine; using DataTransferContracts; using MarkdownLog; using Newtonsoft.Json; namespace ResultsComparer { public class Program { private const string FullBdnJsonFileExtension = "full.json"; public static void Main(string[] args) { // we print a lot of numbers here and we want to make it always in invariant way Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture; Parser.Default.ParseArguments(args).WithParsed(Compare); } private static void Compare(CommandLineOptions args) { if (!Threshold.TryParse(args.StatisticalTestThreshold, out var testThreshold)) { Console.WriteLine($"Invalid Threshold {args.StatisticalTestThreshold}. Examples: 5%, 10ms, 100ns, 1s."); return; } if (!Threshold.TryParse(args.NoiseThreshold, out var noiseThreshold)) { Console.WriteLine($"Invalid Noise Threshold {args.NoiseThreshold}. Examples: 0.3ns 1ns."); return; } var notSame = GetNotSameResults(args, testThreshold, noiseThreshold).ToArray(); if (!notSame.Any()) { Console.WriteLine($"No differences found between the benchmark results with threshold {testThreshold}."); return; } PrintSummary(notSame); PrintTable(notSame, EquivalenceTestConclusion.Slower, args); PrintTable(notSame, EquivalenceTestConclusion.Faster, args); ExportToCsv(notSame, args.CsvPath); ExportToXml(notSame, args.XmlPath); } private static IEnumerable<(string id, Benchmark baseResult, Benchmark diffResult, EquivalenceTestConclusion conclusion)> GetNotSameResults(CommandLineOptions args, Threshold testThreshold, Threshold noiseThreshold) { foreach ((string id, Benchmark baseResult, Benchmark diffResult) in ReadResults(args) .Where(result => result.baseResult.Statistics != null && result.diffResult.Statistics != null)) // failures { var baseValues = baseResult.GetOriginalValues(); var diffValues = diffResult.GetOriginalValues(); var userTresholdResult = StatisticalTestHelper.CalculateTost(MannWhitneyTest.Instance, baseValues, diffValues, testThreshold); if (userTresholdResult.Conclusion == EquivalenceTestConclusion.Same) continue; var noiseResult = StatisticalTestHelper.CalculateTost(MannWhitneyTest.Instance, baseValues, diffValues, noiseThreshold); if (noiseResult.Conclusion == EquivalenceTestConclusion.Same) continue; yield return (id, baseResult, diffResult, userTresholdResult.Conclusion); } } private static void PrintSummary((string id, Benchmark baseResult, Benchmark diffResult, EquivalenceTestConclusion conclusion)[] notSame) { var better = notSame.Where(result => result.conclusion == EquivalenceTestConclusion.Faster); var worse = notSame.Where(result => result.conclusion == EquivalenceTestConclusion.Slower); var betterCount = better.Count(); var worseCount = worse.Count(); // If the baseline doesn't have the same set of tests, you wind up with Infinity in the list of diffs. // Exclude them for purposes of geomean. worse = worse.Where(x => GetRatio(x) != double.PositiveInfinity); better = better.Where(x => GetRatio(x) != double.PositiveInfinity); Console.WriteLine("summary:"); if (betterCount > 0) { var betterGeoMean = Math.Pow(10, better.Skip(1).Aggregate(Math.Log10(GetRatio(better.First())), (x, y) => x + Math.Log10(GetRatio(y))) / better.Count()); Console.WriteLine($"better: {betterCount}, geomean: {betterGeoMean:F3}"); } if (worseCount > 0) { var worseGeoMean = Math.Pow(10, worse.Skip(1).Aggregate(Math.Log10(GetRatio(worse.First())), (x, y) => x + Math.Log10(GetRatio(y))) / worse.Count()); Console.WriteLine($"worse: {worseCount}, geomean: {worseGeoMean:F3}"); } Console.WriteLine($"total diff: {notSame.Length}"); Console.WriteLine(); } private static void PrintTable((string id, Benchmark baseResult, Benchmark diffResult, EquivalenceTestConclusion conclusion)[] notSame, EquivalenceTestConclusion conclusion, CommandLineOptions args) { var data = notSame .Where(result => result.conclusion == conclusion) .OrderByDescending(result => GetRatio(conclusion, result.baseResult, result.diffResult)) .Take(args.TopCount ?? int.MaxValue) .Select(result => new { Id = result.id.Length > 80 ? result.id.Substring(0, 80) : result.id, DisplayValue = GetRatio(conclusion, result.baseResult, result.diffResult), BaseMedian = result.baseResult.Statistics.Median, DiffMedian = result.diffResult.Statistics.Median, Modality = GetModalInfo(result.baseResult) ?? GetModalInfo(result.diffResult) }) .ToArray(); if (!data.Any()) { Console.WriteLine($"No {conclusion} results for the provided threshold = {args.StatisticalTestThreshold} and noise filter = {args.NoiseThreshold}."); Console.WriteLine(); return; } var table = data.ToMarkdownTable().WithHeaders(conclusion.ToString(), conclusion == EquivalenceTestConclusion.Faster ? "base/diff" : "diff/base", "Base Median (ns)", "Diff Median (ns)", "Modality"); foreach (var line in table.ToMarkdown().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)) Console.WriteLine($"| {line.TrimStart()}|"); // the table starts with \t and does not end with '|' and it looks bad so we fix it Console.WriteLine(); } private static IEnumerable<(string id, Benchmark baseResult, Benchmark diffResult)> ReadResults(CommandLineOptions args) { var baseFiles = GetFilesToParse(args.BasePath); var diffFiles = GetFilesToParse(args.DiffPath); if (!baseFiles.Any() || !diffFiles.Any()) throw new ArgumentException($"Provided paths contained no {FullBdnJsonFileExtension} files."); var baseResults = baseFiles.Select(ReadFromFile); var diffResults = diffFiles.Select(ReadFromFile); var filters = args.Filters.Select(pattern => new Regex(WildcardToRegex(pattern), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)).ToArray(); var benchmarkIdToDiffResults = diffResults .SelectMany(result => result.Benchmarks) .Where(benchmarkResult => !filters.Any() || filters.Any(filter => filter.IsMatch(benchmarkResult.FullName))) .ToDictionary(benchmarkResult => benchmarkResult.FullName, benchmarkResult => benchmarkResult); return baseResults .SelectMany(result => result.Benchmarks) .ToDictionary(benchmarkResult => benchmarkResult.FullName, benchmarkResult => benchmarkResult) // we use ToDictionary to make sure the results have unique IDs .Where(baseResult => benchmarkIdToDiffResults.ContainsKey(baseResult.Key)) .Select(baseResult => (baseResult.Key, baseResult.Value, benchmarkIdToDiffResults[baseResult.Key])); } private static void ExportToCsv((string id, Benchmark baseResult, Benchmark diffResult, EquivalenceTestConclusion conclusion)[] notSame, FileInfo csvPath) { if (csvPath == null) return; if (csvPath.Exists) csvPath.Delete(); using (var textWriter = csvPath.CreateText()) { foreach (var (id, baseResult, diffResult, conclusion) in notSame) { textWriter.WriteLine($"\"{id.Replace("\"", "\"\"")}\";base;{conclusion};{string.Join(';', baseResult.GetOriginalValues())}"); textWriter.WriteLine($"\"{id.Replace("\"", "\"\"")}\";diff;{conclusion};{string.Join(';', diffResult.GetOriginalValues())}"); } } Console.WriteLine($"CSV results exported to {csvPath.FullName}"); } private static void ExportToXml((string id, Benchmark baseResult, Benchmark diffResult, EquivalenceTestConclusion conclusion)[] notSame, FileInfo xmlPath) { if (xmlPath == null) { Console.WriteLine("No file given"); return; } if (xmlPath.Exists) xmlPath.Delete(); using (XmlWriter writer = XmlWriter.Create(xmlPath.Open(FileMode.OpenOrCreate, FileAccess.Write, FileShare.Write))) { writer.WriteStartElement("performance-tests"); foreach (var (id, baseResult, diffResult, conclusion) in notSame.Where(x => x.conclusion == EquivalenceTestConclusion.Slower)) { writer.WriteStartElement("test"); writer.WriteAttributeString("name", id); writer.WriteAttributeString("type", baseResult.Type); writer.WriteAttributeString("method", baseResult.Method); writer.WriteAttributeString("time", "0"); writer.WriteAttributeString("result", "Fail"); writer.WriteStartElement("failure"); writer.WriteAttributeString("exception-type", "Regression"); writer.WriteElementString("message", $"{id} has regressed, was {baseResult.Statistics.Median} is {diffResult.Statistics.Median}."); writer.WriteEndElement(); } foreach (var (id, baseResult, diffResult, conclusion) in notSame.Where(x => x.conclusion == EquivalenceTestConclusion.Faster)) { writer.WriteStartElement("test"); writer.WriteAttributeString("name", id); writer.WriteAttributeString("type", baseResult.Type); writer.WriteAttributeString("method", baseResult.Method); writer.WriteAttributeString("time", "0"); writer.WriteAttributeString("result", "Skip"); writer.WriteElementString("reason", $"{id} has improved, was {baseResult.Statistics.Median} is {diffResult.Statistics.Median}."); writer.WriteEndElement(); } writer.WriteEndElement(); writer.Flush(); } Console.WriteLine($"XML results exported to {xmlPath.FullName}"); } private static string[] GetFilesToParse(string path) { if (Directory.Exists(path)) return Directory.GetFiles(path, $"*{FullBdnJsonFileExtension}", SearchOption.AllDirectories); else if (File.Exists(path) || !path.EndsWith(FullBdnJsonFileExtension)) return new[] { path }; else throw new FileNotFoundException($"Provided path does NOT exist or is not a {path} file", path); } // code and magic values taken from BenchmarkDotNet.Analysers.MultimodalDistributionAnalyzer // See http://www.brendangregg.com/FrequencyTrails/modes.html private static string GetModalInfo(Benchmark benchmark) { if (benchmark.Statistics.N < 12) // not enough data to tell return null; double mValue = MValueCalculator.Calculate(benchmark.GetOriginalValues()); if (mValue > 4.2) return "multimodal"; else if (mValue > 3.2) return "bimodal"; else if (mValue > 2.8) return "several?"; return null; } private static double GetRatio((string id, Benchmark baseResult, Benchmark diffResult, EquivalenceTestConclusion conclusion) item) => GetRatio(item.conclusion, item.baseResult, item.diffResult); private static double GetRatio(EquivalenceTestConclusion conclusion, Benchmark baseResult, Benchmark diffResult) => conclusion == EquivalenceTestConclusion.Faster ? baseResult.Statistics.Median / diffResult.Statistics.Median : diffResult.Statistics.Median / baseResult.Statistics.Median; private static BdnResult ReadFromFile(string resultFilePath) { try { return JsonConvert.DeserializeObject(File.ReadAllText(resultFilePath)); } catch (JsonSerializationException) { Console.WriteLine($"Exception while reading the {resultFilePath} file."); throw; } } // https://stackoverflow.com/a/6907849/5852046 not perfect but should work for all we need private static string WildcardToRegex(string pattern) => $"^{Regex.Escape(pattern).Replace(@"\*", ".*").Replace(@"\?", ".")}$"; } }