From e933bb00774617d51f0bee8508937b7d945599b5 Mon Sep 17 00:00:00 2001 From: Andrew Chisholm Date: Sat, 20 Jul 2024 17:57:01 +1000 Subject: [PATCH] Initial ScottPlotExporter with just Bar Plot and Unit Tests (#2560) * Initial ScottPlotExporter with just Bar Plot and Unit Tests * Simplifying project settings, added missing common.props, adde some documentation for config settings. * Removed redundant warning suppressions * Fix missing public documentation * Removed redundant condition * Update tests/BenchmarkDotNet.Exporters.Plotting.Tests/BenchmarkDotNet.Exporters.Plotting.Tests.csproj --------- Co-authored-by: Tim Cassell <35501420+timcassell@users.noreply.github.com> --- BenchmarkDotNet.sln | 14 + .../BenchmarkDotNet.Exporters.Plotting.csproj | 19 ++ .../ScottPlotExporter.cs | 256 ++++++++++++++++++ ...markDotNet.Exporters.Plotting.Tests.csproj | 26 ++ .../ScottPlotExporterTests.cs | 240 ++++++++++++++++ 5 files changed, 555 insertions(+) create mode 100644 src/BenchmarkDotNet.Exporters.Plotting/BenchmarkDotNet.Exporters.Plotting.csproj create mode 100644 src/BenchmarkDotNet.Exporters.Plotting/ScottPlotExporter.cs create mode 100644 tests/BenchmarkDotNet.Exporters.Plotting.Tests/BenchmarkDotNet.Exporters.Plotting.Tests.csproj create mode 100644 tests/BenchmarkDotNet.Exporters.Plotting.Tests/ScottPlotExporterTests.cs diff --git a/BenchmarkDotNet.sln b/BenchmarkDotNet.sln index d51cb4e029..1df6c0aabd 100644 --- a/BenchmarkDotNet.sln +++ b/BenchmarkDotNet.sln @@ -55,6 +55,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BenchmarkDotNet.TestAdapter EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BenchmarkDotNet.Diagnostics.dotMemory", "src\BenchmarkDotNet.Diagnostics.dotMemory\BenchmarkDotNet.Diagnostics.dotMemory.csproj", "{2E2283A3-6DA6-4482-8518-99D6D9F689AB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BenchmarkDotNet.Exporters.Plotting", "src\BenchmarkDotNet.Exporters.Plotting\BenchmarkDotNet.Exporters.Plotting.csproj", "{B92ECCEF-7C27-4012-9E19-679F3C40A6A6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BenchmarkDotNet.Exporters.Plotting.Tests", "tests\BenchmarkDotNet.Exporters.Plotting.Tests\BenchmarkDotNet.Exporters.Plotting.Tests.csproj", "{199AC83E-30BD-40CD-87CE-0C838AC0320D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -149,6 +153,14 @@ Global {2E2283A3-6DA6-4482-8518-99D6D9F689AB}.Debug|Any CPU.Build.0 = Debug|Any CPU {2E2283A3-6DA6-4482-8518-99D6D9F689AB}.Release|Any CPU.ActiveCfg = Release|Any CPU {2E2283A3-6DA6-4482-8518-99D6D9F689AB}.Release|Any CPU.Build.0 = Release|Any CPU + {B92ECCEF-7C27-4012-9E19-679F3C40A6A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B92ECCEF-7C27-4012-9E19-679F3C40A6A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B92ECCEF-7C27-4012-9E19-679F3C40A6A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B92ECCEF-7C27-4012-9E19-679F3C40A6A6}.Release|Any CPU.Build.0 = Release|Any CPU + {199AC83E-30BD-40CD-87CE-0C838AC0320D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {199AC83E-30BD-40CD-87CE-0C838AC0320D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {199AC83E-30BD-40CD-87CE-0C838AC0320D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {199AC83E-30BD-40CD-87CE-0C838AC0320D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -176,6 +188,8 @@ Global {AACA2C63-A85B-47AB-99FC-72C3FF408B14} = {14195214-591A-45B7-851A-19D3BA2413F9} {4C9C89B8-7C4E-4ECF-B3C9-324C8772EDAC} = {D6597E3A-6892-4A68-8E14-042FC941FDA2} {2E2283A3-6DA6-4482-8518-99D6D9F689AB} = {D6597E3A-6892-4A68-8E14-042FC941FDA2} + {B92ECCEF-7C27-4012-9E19-679F3C40A6A6} = {D6597E3A-6892-4A68-8E14-042FC941FDA2} + {199AC83E-30BD-40CD-87CE-0C838AC0320D} = {14195214-591A-45B7-851A-19D3BA2413F9} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {4D9AF12B-1F7F-45A7-9E8C-E4E46ADCBD1F} diff --git a/src/BenchmarkDotNet.Exporters.Plotting/BenchmarkDotNet.Exporters.Plotting.csproj b/src/BenchmarkDotNet.Exporters.Plotting/BenchmarkDotNet.Exporters.Plotting.csproj new file mode 100644 index 0000000000..799a055885 --- /dev/null +++ b/src/BenchmarkDotNet.Exporters.Plotting/BenchmarkDotNet.Exporters.Plotting.csproj @@ -0,0 +1,19 @@ + + + + BenchmarkDotNet plotting export support. + netstandard2.0 + BenchmarkDotNet.Exporters.Plotting + BenchmarkDotNet.Exporters.Plotting + BenchmarkDotNet.Exporters.Plotting + + True + enable + + + + + + + + diff --git a/src/BenchmarkDotNet.Exporters.Plotting/ScottPlotExporter.cs b/src/BenchmarkDotNet.Exporters.Plotting/ScottPlotExporter.cs new file mode 100644 index 0000000000..cfb7a735b7 --- /dev/null +++ b/src/BenchmarkDotNet.Exporters.Plotting/ScottPlotExporter.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using BenchmarkDotNet.Loggers; +using BenchmarkDotNet.Properties; +using BenchmarkDotNet.Reports; +using ScottPlot; +using ScottPlot.Plottables; + +namespace BenchmarkDotNet.Exporters.Plotting +{ + /// + /// Provides plot exports as .png files. + /// + public class ScottPlotExporter : IExporter + { + /// + /// Default instance of the exporter with default configuration. + /// + public static readonly IExporter Default = new ScottPlotExporter(); + + /// + /// Gets the name of the Exporter type. + /// + public string Name => nameof(ScottPlotExporter); + + /// + /// Initializes a new instance of ScottPlotExporter. + /// + /// The width of all plots in pixels (optional). Defaults to 1920. + /// The height of all plots in pixels (optional). Defaults to 1080. + public ScottPlotExporter(int width = 1920, int height = 1080) + { + this.Width = width; + this.Height = height; + this.IncludeBarPlot = true; + this.RotateLabels = true; + } + + /// + /// Gets or sets the width of all plots in pixels. + /// + public int Width { get; set; } + + /// + /// Gets or sets the height of all plots in pixels. + /// + public int Height { get; set; } + + /// + /// Gets or sets a value indicating whether labels for Plot X-axis should be rotated. + /// This allows for longer labels at the expense of chart height. + /// + public bool RotateLabels { get; set; } + + /// + /// Gets or sets a value indicating whether a bar plot for time-per-op + /// measurement values should be exported. + /// + public bool IncludeBarPlot { get; set; } + + /// + /// Not supported. + /// + /// This parameter is not used. + /// This parameter is not used. + /// + public void ExportToLog(Summary summary, ILogger logger) + { + throw new NotSupportedException(); + } + + /// + /// Exports plots to .png file. + /// + /// The summary to be exported. + /// Logger to output to. + /// The file paths of every plot exported. + public IEnumerable ExportToFiles(Summary summary, ILogger consoleLogger) + { + var title = summary.Title; + var version = BenchmarkDotNetInfo.Instance.BrandTitle; + var annotations = GetAnnotations(version); + + var (timeUnit, timeScale) = GetTimeUnit(summary.Reports.SelectMany(m => m.AllMeasurements)); + + foreach (var benchmark in summary.Reports.GroupBy(r => r.BenchmarkCase.Descriptor.Type.Name)) + { + var benchmarkName = benchmark.Key; + + // Get the measurement nanoseconds per op, divided by time scale, grouped by target and Job [param]. + var timeStats = from report in benchmark + let jobId = report.BenchmarkCase.DisplayInfo.Replace(report.BenchmarkCase.Descriptor.DisplayInfo + ": ", string.Empty) + from measurement in report.AllMeasurements + let measurementValue = measurement.Nanoseconds / measurement.Operations + group measurementValue / timeScale by (Target: report.BenchmarkCase.Descriptor.WorkloadMethodDisplayInfo, JobId: jobId) into g + select (g.Key.Target, g.Key.JobId, Mean: g.Average(), StdError: StandardError(g.ToList())); + + if (this.IncludeBarPlot) + { + // -barplot.png + yield return CreateBarPlot( + $"{title} - {benchmarkName}", + Path.Combine(summary.ResultsDirectoryPath, $"{title}-{benchmarkName}-barplot.png"), + $"Time ({timeUnit})", + "Target", + timeStats, + annotations); + } + + /* TODO: Rest of the RPlotExporter plots. + -boxplot.png + --density.png + --facetTimeline.png + --facetTimelineSmooth.png + ---timelineSmooth.png + ---timelineSmooth.png*/ + } + } + + /// + /// Calculate Standard Deviation. + /// + /// Values to calculate from. + /// Standard deviation of values. + private static double StandardError(IReadOnlyList values) + { + double average = values.Average(); + double sumOfSquaresOfDifferences = values.Select(val => (val - average) * (val - average)).Sum(); + double standardDeviation = Math.Sqrt(sumOfSquaresOfDifferences / values.Count); + return standardDeviation / Math.Sqrt(values.Count); + } + + /// + /// Gets the lowest appropriate time scale across all measurements. + /// + /// All measurements + /// A unit and scaling factor to convert from nanoseconds. + private (string Unit, double ScaleFactor) GetTimeUnit(IEnumerable values) + { + var minValue = values.Select(m => m.Nanoseconds / m.Operations).DefaultIfEmpty(0d).Min(); + if (minValue > 1000000000d) + { + return ("sec", 1000000000d); + } + + if (minValue > 1000000d) + { + return ("ms", 1000000d); + } + + if (minValue > 1000d) + { + return ("us", 1000d); + } + + return ("ns", 1d); + } + + private string CreateBarPlot(string title, string fileName, string yLabel, string xLabel, IEnumerable<(string Target, string JobId, double Mean, double StdError)> data, IReadOnlyList annotations) + { + Plot plt = new Plot(); + plt.Title(title, 28); + plt.YLabel(yLabel); + plt.XLabel(xLabel); + + var palette = new ScottPlot.Palettes.Category10(); + + var legendPalette = data.Select(d => d.JobId) + .Distinct() + .Select((jobId, index) => (jobId, index)) + .ToDictionary(t => t.jobId, t => palette.GetColor(t.index)); + + plt.Legend.IsVisible = true; + plt.Legend.Location = Alignment.UpperRight; + var legend = data.Select(d => d.JobId) + .Distinct() + .Select((label, index) => new LegendItem() + { + Label = label, + FillColor = legendPalette[label] + }) + .ToList(); + + plt.Legend.ManualItems.AddRange(legend); + + var jobCount = plt.Legend.ManualItems.Count; + var ticks = data + .Select((d, index) => new Tick(index, d.Target)) + .ToArray(); + plt.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.NumericManual(ticks); + plt.Axes.Bottom.MajorTickStyle.Length = 0; + + if (this.RotateLabels) + { + plt.Axes.Bottom.TickLabelStyle.Rotation = 45; + plt.Axes.Bottom.TickLabelStyle.Alignment = Alignment.MiddleLeft; + + // determine the width of the largest tick label + float largestLabelWidth = 0; + foreach (Tick tick in ticks) + { + PixelSize size = plt.Axes.Bottom.TickLabelStyle.Measure(tick.Label); + largestLabelWidth = Math.Max(largestLabelWidth, size.Width); + } + + // ensure axis panels do not get smaller than the largest label + plt.Axes.Bottom.MinimumSize = largestLabelWidth; + plt.Axes.Right.MinimumSize = largestLabelWidth; + } + + var bars = data + .Select((d, index) => new Bar() + { + Position = ticks[index].Position, + Value = d.Mean, + Error = d.StdError, + FillColor = legendPalette[d.JobId] + }); + plt.Add.Bars(bars); + + // Tell the plot to autoscale with no padding beneath the bars + plt.Axes.Margins(bottom: 0, right: .2); + + plt.PlottableList.AddRange(annotations); + + plt.SavePng(fileName, this.Width, this.Height); + return Path.GetFullPath(fileName); + } + + /// + /// Provides a list of annotations to put over the data area. + /// + /// The version to be displayed. + /// A list of annotations for every plot. + private IReadOnlyList GetAnnotations(string version) + { + var versionAnnotation = new Annotation() + { + Label = + { + Text = version, + FontSize = 14, + ForeColor = new Color(0, 0, 0, 100) + }, + OffsetY = 10, + OffsetX = 20, + Alignment = Alignment.LowerRight + }; + + + return new[] { versionAnnotation }; + } + } +} \ No newline at end of file diff --git a/tests/BenchmarkDotNet.Exporters.Plotting.Tests/BenchmarkDotNet.Exporters.Plotting.Tests.csproj b/tests/BenchmarkDotNet.Exporters.Plotting.Tests/BenchmarkDotNet.Exporters.Plotting.Tests.csproj new file mode 100644 index 0000000000..0c61fd72af --- /dev/null +++ b/tests/BenchmarkDotNet.Exporters.Plotting.Tests/BenchmarkDotNet.Exporters.Plotting.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0;net462 + false + true + true + false + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/tests/BenchmarkDotNet.Exporters.Plotting.Tests/ScottPlotExporterTests.cs b/tests/BenchmarkDotNet.Exporters.Plotting.Tests/ScottPlotExporterTests.cs new file mode 100644 index 0000000000..a585b9df86 --- /dev/null +++ b/tests/BenchmarkDotNet.Exporters.Plotting.Tests/ScottPlotExporterTests.cs @@ -0,0 +1,240 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Loggers; +using BenchmarkDotNet.Tests.Mocks; +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using Xunit; + +namespace BenchmarkDotNet.Exporters.Plotting.Tests +{ + public class ScottPlotExporterTests + { + public static TheoryData GetGroupBenchmarkTypes() + { + var data = new TheoryData(); + foreach (var type in typeof(BaselinesBenchmarks).GetNestedTypes()) + data.Add(type); + return data; + } + + [Theory] + [MemberData(nameof(GetGroupBenchmarkTypes))] + public void BarPlots(Type benchmarkType) + { + var logger = new AccumulationLogger(); + logger.WriteLine("=== " + benchmarkType.Name + " ==="); + + var exporter = new ScottPlotExporter(); + var summary = MockFactory.CreateSummary(benchmarkType); + var filePaths = exporter.ExportToFiles(summary, logger).ToList(); + Assert.NotEmpty(filePaths); + Assert.All(filePaths, f => File.Exists(f)); + } + + [SuppressMessage("ReSharper", "InconsistentNaming")] + public static class BaselinesBenchmarks + { + /* NoBaseline */ + [RankColumn, LogicalGroupColumn, BaselineColumn] + [SimpleJob(id: "Job1"), SimpleJob(id: "Job2")] + public class NoBaseline_MethodsParamsJobs + { + [Params(2, 10)] public int Param; + + [Benchmark] public void Base() { } + [Benchmark] public void Foo() { } + [Benchmark] public void Bar() { } + } + + [RankColumn, LogicalGroupColumn, BaselineColumn] + [SimpleJob(id: "Job1"), SimpleJob(id: "Job2")] + [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByMethod)] + public class NoBaseline_MethodsParamsJobs_GroupByMethod + { + [Params(2, 10)] public int Param; + + [Benchmark, BenchmarkCategory("CatA")] public void Base() { } + [Benchmark, BenchmarkCategory("CatB")] public void Foo() { } + [Benchmark, BenchmarkCategory("CatB")] public void Bar() { } + } + + [RankColumn, LogicalGroupColumn, BaselineColumn] + [SimpleJob(id: "Job1"), SimpleJob(id: "Job2")] + [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByJob)] + public class NoBaseline_MethodsParamsJobs_GroupByJob + { + [Params(2, 10)] public int Param; + + [Benchmark, BenchmarkCategory("CatA")] public void Base() { } + [Benchmark, BenchmarkCategory("CatB")] public void Foo() { } + [Benchmark, BenchmarkCategory("CatB")] public void Bar() { } + } + + [RankColumn, LogicalGroupColumn, BaselineColumn] + [SimpleJob(id: "Job1"), SimpleJob(id: "Job2")] + [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByParams)] + public class NoBaseline_MethodsParamsJobs_GroupByParams + { + [Params(2, 10)] public int Param; + + [Benchmark, BenchmarkCategory("CatA")] public void Base() { } + [Benchmark, BenchmarkCategory("CatB")] public void Foo() { } + [Benchmark, BenchmarkCategory("CatB")] public void Bar() { } + } + + [RankColumn, LogicalGroupColumn, BaselineColumn] + [SimpleJob(id: "Job1"), SimpleJob(id: "Job2")] + [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] + public class NoBaseline_MethodsParamsJobs_GroupByCategory + { + [Params(2, 10)] public int Param; + + [Benchmark(Baseline = true), BenchmarkCategory("CatA")] + public void A1() { } + + [Benchmark, BenchmarkCategory("CatA")] public void A2() { } + + [Benchmark(Baseline = true), BenchmarkCategory("CatB")] + public void B1() { } + + [Benchmark, BenchmarkCategory("CatB")] public void B2() { } + } + + [RankColumn, LogicalGroupColumn, BaselineColumn] + [SimpleJob(id: "Job1"), SimpleJob(id: "Job2")] + [GroupBenchmarksBy( + BenchmarkLogicalGroupRule.ByMethod, + BenchmarkLogicalGroupRule.ByJob, + BenchmarkLogicalGroupRule.ByParams, + BenchmarkLogicalGroupRule.ByCategory)] + public class NoBaseline_MethodsParamsJobs_GroupByAll + { + [Params(2, 10)] public int Param; + + [Benchmark(Baseline = true), BenchmarkCategory("CatA")] + public void A1() { } + + [Benchmark, BenchmarkCategory("CatA")] public void A2() { } + + [Benchmark(Baseline = true), BenchmarkCategory("CatB")] + public void B1() { } + + [Benchmark, BenchmarkCategory("CatB")] public void B2() { } + } + + /* MethodBaseline */ + + [RankColumn, LogicalGroupColumn, BaselineColumn] + public class MethodBaseline_Methods + { + [Benchmark(Baseline = true)] public void Base() { } + [Benchmark] public void Foo() { } + [Benchmark] public void Bar() { } + } + + [RankColumn, LogicalGroupColumn, BaselineColumn] + public class MethodBaseline_MethodsParams + { + [Params(2, 10)] public int Param; + + [Benchmark(Baseline = true)] public void Base() { } + [Benchmark] public void Foo() { } + [Benchmark] public void Bar() { } + } + + [RankColumn, LogicalGroupColumn, BaselineColumn] + [SimpleJob(id: "Job1"), SimpleJob(id: "Job2")] + public class MethodBaseline_MethodsJobs + { + [Benchmark(Baseline = true)] public void Base() { } + [Benchmark] public void Foo() { } + [Benchmark] public void Bar() { } + } + + [RankColumn, LogicalGroupColumn, BaselineColumn] + [SimpleJob(id: "Job1"), SimpleJob(id: "Job2")] + public class MethodBaseline_MethodsParamsJobs + { + [Params(2, 10)] public int Param; + + [Benchmark(Baseline = true)] public void Base() { } + [Benchmark] public void Foo() { } + [Benchmark] public void Bar() { } + } + + /* JobBaseline */ + + [RankColumn, LogicalGroupColumn, BaselineColumn] + [SimpleJob(id: "Job1", baseline: true), SimpleJob(id: "Job2")] + public class JobBaseline_MethodsJobs + { + [Benchmark] public void Base() { } + [Benchmark] public void Foo() { } + [Benchmark] public void Bar() { } + } + + [RankColumn, LogicalGroupColumn, BaselineColumn] + [SimpleJob(id: "Job1", baseline: true), SimpleJob(id: "Job2")] + public class JobBaseline_MethodsParamsJobs + { + [Params(2, 10)] public int Param; + + [Benchmark] public void Base() { } + [Benchmark] public void Foo() { } + [Benchmark] public void Bar() { } + } + + /* MethodJobBaseline */ + + [RankColumn, LogicalGroupColumn, BaselineColumn] + [SimpleJob(id: "Job1", baseline: true), SimpleJob(id: "Job2")] + public class MethodJobBaseline_MethodsJobs + { + [Benchmark(Baseline = true)] public void Foo() { } + [Benchmark] public void Bar() { } + } + + [RankColumn, LogicalGroupColumn, BaselineColumn] + [SimpleJob(id: "Job1", baseline: true), SimpleJob(id: "Job2")] + public class MethodJobBaseline_MethodsJobsParams + { + [Params(2, 10)] public int Param; + + [Benchmark(Baseline = true)] public void Foo() { } + [Benchmark] public void Bar() { } + } + + /* Invalid */ + + [RankColumn, LogicalGroupColumn, BaselineColumn] + public class Invalid_TwoMethodBaselines + { + [Benchmark(Baseline = true)] public void Foo() { } + [Benchmark(Baseline = true)] public void Bar() { } + } + + [RankColumn, LogicalGroupColumn, BaselineColumn] + [SimpleJob(id: "Job1", baseline: true), SimpleJob(id: "Job2", baseline: true)] + public class Invalid_TwoJobBaselines + { + [Benchmark] public void Foo() { } + [Benchmark] public void Bar() { } + } + + /* Escape Params */ + + public class Escape_ParamsAndArguments + { + [Params("\t", "\n")] public string StringParam; + + [Arguments('\t')] + [Arguments('\n')] + [Benchmark] public void Foo(char charArg) { } + [Benchmark] public void Bar() { } + } + } + } +} \ No newline at end of file