diff --git a/src/BenchmarkDotNet.Exporters.Plotting/ScottPlotExporter.cs b/src/BenchmarkDotNet.Exporters.Plotting/ScottPlotExporter.cs index 77695d4ae7..5063a3cd33 100644 --- a/src/BenchmarkDotNet.Exporters.Plotting/ScottPlotExporter.cs +++ b/src/BenchmarkDotNet.Exporters.Plotting/ScottPlotExporter.cs @@ -8,6 +8,7 @@ using BenchmarkDotNet.Reports; using ScottPlot; using ScottPlot.Plottables; +using ScottPlot.Statistics; namespace BenchmarkDotNet.Exporters.Plotting { @@ -37,6 +38,7 @@ public ScottPlotExporter(int width = 1920, int height = 1080) this.Height = height; this.IncludeBarPlot = true; this.IncludeBoxPlot = true; + this.IncludeHistogramPlot = true; this.RotateLabels = true; } @@ -78,6 +80,12 @@ public ScottPlotExporter(int width = 1920, int height = 1080) /// public bool IncludeBoxPlot { get; set; } + /// + /// Gets or sets a value indicating whether a histogram plot for time-per-op + /// measurement values should be exported. + /// + public bool IncludeHistogramPlot { get; set; } + /// /// Not supported. /// @@ -141,6 +149,18 @@ where measurement.Is(IterationMode.Workload, IterationStage.Result) annotations); } + if (this.IncludeHistogramPlot) + { + // -histogramplot.png + yield return CreateHistogramPlot( + $"{title} - {benchmarkName}", + Path.Combine(summary.ResultsDirectoryPath, $"{title}-{benchmarkName}-histogramplot.png"), + "Count", + $"Time ({timeUnit})", + timeStats, + annotations); + } + /* TODO: Rest of the RPlotExporter plots. --density.png --facetTimeline.png @@ -347,6 +367,60 @@ private string CreateBoxPlot(string title, string fileName, string yLabel, strin return Path.GetFullPath(fileName); } + // This doesn't support RotateTicks. I figure it's not necessary, since they're not category labels and thus shouldn't be very long + private string CreateHistogramPlot(string title, string fileName, string yLabel, string xLabel, IEnumerable data, IReadOnlyList annotations) + { + Plot plt = new Plot(); + plt.Title(title, this.TitleFontSize); + plt.YLabel(yLabel, this.FontSize); + plt.XLabel(xLabel, this.FontSize); + + 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).WithAlpha(0.5)); + + plt.Legend.IsVisible = true; + plt.Legend.Alignment = Alignment.UpperRight; + plt.Legend.FontSize = this.FontSize; + var legend = data.Select(d => d.JobId) + .Distinct() + .Select((label, index) => new LegendItem() + { + LabelText = label, + FillColor = legendPalette[label] + }) + .ToList(); + + plt.Legend.ManualItems.AddRange(legend); + + var jobCount = plt.Legend.ManualItems.Count; + + plt.Axes.Left.TickLabelStyle.FontSize = this.FontSize; + plt.Axes.Bottom.MajorTickStyle.Length = 0; + plt.Axes.Bottom.TickLabelStyle.FontSize = this.FontSize; + + // 5 is an arbitrary multiplier, this may need to be responsive to the number of points + // There are theoretically optimzal binsizes (e.g. the 2 * IQR * N^(-1/3) rule), but they tend to make significant assumptions about the data + var binWidth = data.GroupBy(s => s.Target).SelectMany(tg => tg.Select(stats => 5 * (stats.Max - stats.Min) / stats.Values.Count)).Min(); + + foreach (var (targetGroup, targetGroupIndex) in data.GroupBy(s => s.Target).Select((targetGroup, index) => (targetGroup, index))) + { + var hists = targetGroup.Select((job) => (job, hist: Histogram.WithBinSize(binWidth, job.Values))); + + foreach (var (job, hist) in hists) { + plt.Add.Histogram(hist, legendPalette[job.JobId]); + } + } + + 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. /// diff --git a/tests/BenchmarkDotNet.Exporters.Plotting.Tests/ScottPlotExporterTests.cs b/tests/BenchmarkDotNet.Exporters.Plotting.Tests/ScottPlotExporterTests.cs index e717b6b84d..01c35fcc93 100644 --- a/tests/BenchmarkDotNet.Exporters.Plotting.Tests/ScottPlotExporterTests.cs +++ b/tests/BenchmarkDotNet.Exporters.Plotting.Tests/ScottPlotExporterTests.cs @@ -35,6 +35,7 @@ public void BarPlots(Type benchmarkType) { IncludeBarPlot = true, IncludeBoxPlot = false, + IncludeHistogramPlot = false, }; var summary = MockFactory.CreateSummary(benchmarkType); var filePaths = exporter.ExportToFiles(summary, logger).ToList(); @@ -57,6 +58,7 @@ public void BoxPlots(Type benchmarkType) { IncludeBarPlot = false, IncludeBoxPlot = true, + IncludeHistogramPlot = false, }; var summary = MockFactory.CreateSummaryWithBiasedDistribution(benchmarkType, 1, 4, 10, 9); var filePaths = exporter.ExportToFiles(summary, logger).ToList(); @@ -79,6 +81,7 @@ public void BoxPlotsWithOneMeasurement(Type benchmarkType) { IncludeBarPlot = false, IncludeBoxPlot = true, + IncludeHistogramPlot = false, }; var summary = MockFactory.CreateSummaryWithBiasedDistribution(benchmarkType, 1, 4, 10, 1); var filePaths = exporter.ExportToFiles(summary, logger).ToList(); @@ -90,6 +93,29 @@ public void BoxPlotsWithOneMeasurement(Type benchmarkType) output.WriteLine(logger.GetLog()); } + [Theory] + [MemberData(nameof(GetGroupBenchmarkTypes))] + public void HistogramPlots(Type benchmarkType) + { + var logger = new AccumulationLogger(); + logger.WriteLine("=== " + benchmarkType.Name + " ==="); + + var exporter = new ScottPlotExporter() + { + IncludeBarPlot = false, + IncludeBoxPlot = false, + IncludeHistogramPlot = true, + }; + var summary = MockFactory.CreateSummaryWithBiasedDistribution(benchmarkType, 1, 4, 10, 9); + var filePaths = exporter.ExportToFiles(summary, logger).ToList(); + Assert.NotEmpty(filePaths); + Assert.All(filePaths, f => File.Exists(f)); + + foreach (string filePath in filePaths) + logger.WriteLine($"* {filePath}"); + output.WriteLine(logger.GetLog()); + } + [SuppressMessage("ReSharper", "InconsistentNaming")] public static class BaselinesBenchmarks {