Skip to content

Commit

Permalink
Parallelise Report Generator
Browse files Browse the repository at this point in the history
I've had a go at trying to parallelise the - aimed particularly at helping GitHub Actions / Azure DevOps where the report generator can be quite slow, presumably due to disk IO.  I've read some issues about previous attempts and realised it's not as simple as processing classes in parallel as not all the `IReportBuilder` implements support concurrency.

With this in mind, I believe two parts of the report generation process (`ReportGenerator`) can be parallelised

1. The initial File Analysis can be done concurrently with the report generation
2. Introduced `IParallelisableReportBuilder` - `IReportBuilder`s that also implement `IParallelisableReportBuilder` can then be processed in parallel

this PR has a draft implementation of this, and with the change, I have been able to get report generation (measuring time spent in `Generator.GenerateReport`) down from 21 secs to down as low as 4 seconds with extreme concurrency, and 6secs with more reasonable concurrency levels.

This PR is still a bit of a work in progress - in particular I need to plumb the concurrency level through as a config option, and I also want to do some testing with GitHub Actions / Azure Devops.  However I wanted to raise it in it's current form for some initial thoughts on the approach.

Note, this parallelisation change highly benefits form #TODO
  • Loading branch information
afscrome committed Sep 14, 2024
1 parent 78a1a92 commit 3be67d6
Show file tree
Hide file tree
Showing 14 changed files with 129 additions and 53 deletions.
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using Palmmedia.ReportGenerator.Core.Parser.Analysis;
Expand All @@ -18,7 +19,7 @@ public class HtmlBlueRedReportBuilder : HtmlReportBuilderBase
/// <summary>
/// Dictionary containing the filenames of the class reports by class.
/// </summary>
private readonly IDictionary<string, string> fileNameByClass = new Dictionary<string, string>();
private readonly ConcurrentDictionary<string, string> fileNameByClass = new ConcurrentDictionary<string, string>();

/// <summary>
/// Initializes a new instance of the <see cref="HtmlBlueRedReportBuilder" /> class.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using Palmmedia.ReportGenerator.Core.Parser.Analysis;
Expand Down Expand Up @@ -33,7 +34,7 @@ public override void CreateClassReport(Class @class, IEnumerable<FileAnalysis> f
/// <param name="summaryResult">The summary result.</param>
public override void CreateSummaryReport(SummaryResult summaryResult)
{
using (var renderer = new HtmlRenderer(new Dictionary<string, string>(), true, HtmlMode.InlineCssAndJavaScript, new string[] { "custom_adaptive.css", "custom_bluered.css" }, "custom.css"))
using (var renderer = new HtmlRenderer(new ConcurrentDictionary<string, string>(), true, HtmlMode.InlineCssAndJavaScript, new string[] { "custom_adaptive.css", "custom_bluered.css" }, "custom.css"))
{
this.CreateSummaryReport(renderer, summaryResult);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using Palmmedia.ReportGenerator.Core.Parser.Analysis;
Expand All @@ -18,7 +19,7 @@ public class HtmlDarkReportBuilder : HtmlReportBuilderBase
/// <summary>
/// Dictionary containing the filenames of the class reports by class.
/// </summary>
private readonly IDictionary<string, string> fileNameByClass = new Dictionary<string, string>();
private readonly ConcurrentDictionary<string, string> fileNameByClass = new ConcurrentDictionary<string, string>();

/// <summary>
/// Initializes a new instance of the <see cref="HtmlDarkReportBuilder" /> class.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using Palmmedia.ReportGenerator.Core.Parser.Analysis;
Expand All @@ -13,7 +14,7 @@ public class HtmlInlineAzurePipelinesDarkReportBuilder : HtmlReportBuilderBase
/// <summary>
/// Dictionary containing the filenames of the class reports by class.
/// </summary>
private readonly IDictionary<string, string> fileNameByClass = new Dictionary<string, string>();
private readonly ConcurrentDictionary<string, string> fileNameByClass = new ConcurrentDictionary<string, string>();

/// <summary>
/// Gets the report type.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using Palmmedia.ReportGenerator.Core.Parser.Analysis;
Expand All @@ -13,7 +14,7 @@ public class HtmlInlineAzurePipelinesLightReportBuilder : HtmlReportBuilderBase
/// <summary>
/// Dictionary containing the filenames of the class reports by class.
/// </summary>
private readonly IDictionary<string, string> fileNameByClass = new Dictionary<string, string>();
private readonly ConcurrentDictionary<string, string> fileNameByClass = new ConcurrentDictionary<string, string>();

/// <summary>
/// Gets the report type.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using Palmmedia.ReportGenerator.Core.Parser.Analysis;
Expand All @@ -13,7 +14,7 @@ public class HtmlInlineAzurePipelinesReportBuilder : HtmlReportBuilderBase
/// <summary>
/// Dictionary containing the filenames of the class reports by class.
/// </summary>
private readonly IDictionary<string, string> fileNameByClass = new Dictionary<string, string>();
private readonly ConcurrentDictionary<string, string> fileNameByClass = new ConcurrentDictionary<string, string>();

/// <summary>
/// Gets the report type.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using Palmmedia.ReportGenerator.Core.Parser.Analysis;
Expand All @@ -13,7 +14,7 @@ public class HtmlInlineCssAndJavaScriptReportBuilder : HtmlReportBuilderBase
/// <summary>
/// Dictionary containing the filenames of the class reports by class.
/// </summary>
private readonly IDictionary<string, string> fileNameByClass = new Dictionary<string, string>();
private readonly ConcurrentDictionary<string, string> fileNameByClass = new ConcurrentDictionary<string, string>();

/// <summary>
/// Gets the report type.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using Palmmedia.ReportGenerator.Core.Parser.Analysis;
Expand All @@ -18,7 +19,7 @@ public class HtmlLightReportBuilder : HtmlReportBuilderBase
/// <summary>
/// Dictionary containing the filenames of the class reports by class.
/// </summary>
private readonly IDictionary<string, string> fileNameByClass = new Dictionary<string, string>();
private readonly ConcurrentDictionary<string, string> fileNameByClass = new ConcurrentDictionary<string, string>();

/// <summary>
/// Initializes a new instance of the <see cref="HtmlLightReportBuilder" /> class.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using Palmmedia.ReportGenerator.Core.Parser.Analysis;
Expand All @@ -18,7 +19,7 @@ public class HtmlReportBuilder : HtmlReportBuilderBase
/// <summary>
/// Dictionary containing the filenames of the class reports by class.
/// </summary>
private readonly IDictionary<string, string> fileNameByClass = new Dictionary<string, string>();
private readonly ConcurrentDictionary<string, string> fileNameByClass = new ConcurrentDictionary<string, string>();

/// <summary>
/// Initializes a new instance of the <see cref="HtmlReportBuilder" /> class.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace Palmmedia.ReportGenerator.Core.Reporting.Builders
/// <summary>
/// Implementation of <see cref="IReportBuilder"/> that uses <see cref="IHtmlRenderer"/> to create reports.
/// </summary>
public abstract class HtmlReportBuilderBase : IReportBuilder
public abstract class HtmlReportBuilderBase : IParallelisableReportBuilder
{
/// <summary>
/// The Logger.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using Palmmedia.ReportGenerator.Core.Parser.Analysis;
Expand Down Expand Up @@ -33,7 +34,7 @@ public override void CreateClassReport(Class @class, IEnumerable<FileAnalysis> f
/// <param name="summaryResult">The summary result.</param>
public override void CreateSummaryReport(SummaryResult summaryResult)
{
using (var renderer = new HtmlRenderer(new Dictionary<string, string>(), true, HtmlMode.InlineCssAndJavaScript))
using (var renderer = new HtmlRenderer(new ConcurrentDictionary<string, string>(), true, HtmlMode.InlineCssAndJavaScript))
{
this.CreateSummaryReport(renderer, summaryResult);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
Expand Down Expand Up @@ -58,7 +59,7 @@ internal class HtmlRenderer : IHtmlRenderer, IDisposable
/// <summary>
/// Dictionary containing the filenames of the class reports by class.
/// </summary>
private readonly IDictionary<string, string> fileNameByClass;
private readonly ConcurrentDictionary<string, string> fileNameByClass;

/// <summary>
/// Indicates that only a summary report is created (no class reports).
Expand Down Expand Up @@ -109,7 +110,7 @@ internal class HtmlRenderer : IHtmlRenderer, IDisposable
/// <param name="cssFileResource">Optional CSS file resource.</param>
/// <param name="additionalCssFileResource">Optional additional CSS file resource.</param>
internal HtmlRenderer(
IDictionary<string, string> fileNameByClass,
ConcurrentDictionary<string, string> fileNameByClass,
bool onlySummary,
HtmlMode htmlMode,
string cssFileResource = "custom.css",
Expand All @@ -132,7 +133,7 @@ internal HtmlRenderer(
/// <param name="additionalCssFileResources">Optional additional CSS file resources.</param>
/// <param name="cssFileResource">Optional CSS file resource.</param>
internal HtmlRenderer(
IDictionary<string, string> fileNameByClass,
ConcurrentDictionary<string, string> fileNameByClass,
bool onlySummary,
HtmlMode htmlMode,
string[] additionalCssFileResources,
Expand Down Expand Up @@ -1557,7 +1558,7 @@ private string GetClassReportFilename(Assembly assembly, string className)
while (this.fileNameByClass.Values.Any(v => v.Equals(fileName, StringComparison.OrdinalIgnoreCase)));
}

this.fileNameByClass.Add(key, fileName);
this.fileNameByClass[key] = fileName;
}

return fileName;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Palmmedia.ReportGenerator.Core.Reporting
{
/// <summary>
/// Interface indicating that an <see cref="IReportBuilder"/> can build multiple reports concurrently.
/// </summary>
public interface IParallelisableReportBuilder : IReportBuilder { }
}
134 changes: 96 additions & 38 deletions src/ReportGenerator.Core/Reporting/ReportGenerator.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Palmmedia.ReportGenerator.Core.Common;
using Palmmedia.ReportGenerator.Core.Logging;
Expand All @@ -16,6 +18,9 @@ namespace Palmmedia.ReportGenerator.Core.Reporting
/// </summary>
internal class ReportGenerator
{
// TODO: Make this configurable
private const int MaxConcurrency = 8;

/// <summary>
/// The Logger.
/// </summary>
Expand All @@ -34,7 +39,7 @@ internal class ReportGenerator
/// <summary>
/// The renderers.
/// </summary>
private readonly IEnumerable<IReportBuilder> renderers;
private readonly List<IReportBuilder> renderers;

/// <summary>
/// Initializes a new instance of the <see cref="ReportGenerator" /> class.
Expand All @@ -46,7 +51,7 @@ internal ReportGenerator(IFileReader fileReader, ParserResult parserResult, IEnu
{
this.fileReader = fileReader ?? throw new ArgumentNullException(nameof(fileReader));
this.parserResult = parserResult ?? throw new ArgumentNullException(nameof(parserResult));
this.renderers = renderers ?? throw new ArgumentNullException(nameof(renderers));
this.renderers = renderers?.ToList() ?? throw new ArgumentNullException(nameof(renderers));
}

/// <summary>
Expand All @@ -58,54 +63,73 @@ internal ReportGenerator(IFileReader fileReader, ParserResult parserResult, IEnu
/// <param name="tag">The custom tag (e.g. build number).</param>
internal void CreateReport(bool addHistoricCoverage, List<HistoricCoverage> overallHistoricCoverages, DateTime executionTime, string tag)
{
// TODO: Can we change overallHistoricCoverages to a ConcurrentBag to avoid this?
object overallHistoricCoveragesLock = new object();

// TODO: This probably belongs in the ReportGenerator Console and/or Global Tool
// AND can probably be a bit smarter.
//
// If there aren't enough threads in the thread pool, the Parallelism here can deadlock until the pool grows large enough
// With all avaialble threads being used in class analysis, but with ConcurrentReportBuilder threads having
// to wait for the thread pool to grow. By default, .Net core adds 2 threads every 0.5 secs
// App will become responsive within a few seconds, but given that report generator can complete in tens of seconds,
// these stalls become significant
ThreadPool.SetMinThreads(200, 200);

var allClasses = this.parserResult.Assemblies.SelectMany(a => a.Classes);
var classAnalysis = Partitioner.Create(allClasses, EnumerablePartitionerOptions.NoBuffering)
.AsParallel()
.AsOrdered()
.WithDegreeOfParallelism(MaxConcurrency)
.Select(AnalyseClass)
.AsSequential();

int numberOfClasses = this.parserResult.Assemblies.SafeSum(a => a.Classes.Count());

Logger.DebugFormat(Resources.AnalyzingClasses, numberOfClasses);

int counter = 0;
var concurrentRenderers = this.renderers.OfType<IParallelisableReportBuilder>().ToList();
var sequentialRenderers = this.renderers.Except(concurrentRenderers).ToList();

foreach (var assembly in this.parserResult.Assemblies)
var concurrentRenderQueue = new BlockingCollection<(IReportBuilder renderer, Class @class, List<FileAnalysis> analysis)>(MaxConcurrency);
Task concurrentRendererTask = Task.CompletedTask;

if (concurrentRenderers.Any())
{
foreach (var @class in assembly.Classes)
concurrentRendererTask = Task.Factory.StartNew(() =>
{
counter++;

Logger.DebugFormat(
Resources.CreatingReport,
counter,
numberOfClasses,
@class.Assembly.ShortName,
@class.Name);

var fileAnalyses = @class.Files.Select(f => f.AnalyzeFile(this.fileReader)).ToArray();

if (addHistoricCoverage)
{
var historicCoverage = new HistoricCoverage(@class, executionTime, tag);
@class.AddHistoricCoverage(historicCoverage);
overallHistoricCoverages.Add(historicCoverage);
}
Partitioner.Create(concurrentRenderQueue.GetConsumingEnumerable(), EnumerablePartitionerOptions.NoBuffering)
.AsParallel()
.WithDegreeOfParallelism(MaxConcurrency)
.ForAll(x => RenderReport(x.renderer, x.@class, x.analysis));
});
}

Parallel.ForEach(
this.renderers,
renderer =>
{
try
{
renderer.CreateClassReport(@class, fileAnalyses);
}
catch (Exception ex)
{
Logger.ErrorFormat(
Resources.ErrorDuringRenderingClassReport,
@class.Name,
renderer.ReportType,
ex.GetExceptionMessageForDisplay());
}
});
foreach (var (@class, analysis) in classAnalysis)
{
counter++;
Logger.DebugFormat(
Resources.CreatingReport,
counter,
numberOfClasses,
@class.Assembly.ShortName,
@class.Name);

foreach (var renderer in concurrentRenderers)
{
concurrentRenderQueue.Add((renderer, @class, analysis));
}

sequentialRenderers
.AsParallel()
.WithMergeOptions(ParallelMergeOptions.NotBuffered)
.ForAll(x => RenderReport(x, @class, analysis));
}

concurrentRenderQueue.CompleteAdding();
concurrentRendererTask.Wait();

Logger.Debug(Resources.CreatingSummary);
SummaryResult summaryResult = new SummaryResult(this.parserResult);

Expand All @@ -128,6 +152,40 @@ internal void CreateReport(bool addHistoricCoverage, List<HistoricCoverage> over
}
}
}

(Class @class, List<FileAnalysis> analysis) AnalyseClass(Class @class)
{
var fileAnalyses = @class.Files.Select(f => f.AnalyzeFile(this.fileReader)).ToList();

if (addHistoricCoverage)
{
var historicCoverage = new HistoricCoverage(@class, executionTime, tag);
@class.AddHistoricCoverage(historicCoverage);

lock (overallHistoricCoveragesLock)
{
overallHistoricCoverages.Add(historicCoverage);
}
}

return (@class, fileAnalyses);
}
}

private static void RenderReport(IReportBuilder renderer, Class @class, List<FileAnalysis> analysis)
{
try
{
renderer.CreateClassReport(@class, analysis);
}
catch (Exception ex)
{
Logger.ErrorFormat(
Resources.ErrorDuringRenderingClassReport,
@class.Name,
renderer.ReportType,
ex.GetExceptionMessageForDisplay());
}
}
}
}

0 comments on commit 3be67d6

Please sign in to comment.