diff --git a/EnumerableAsyncProcessor.Pipeline/Modules/BuildBenchmarkProjectsModule.cs b/EnumerableAsyncProcessor.Pipeline/Modules/BuildBenchmarkProjectsModule.cs new file mode 100644 index 0000000..678c1f0 --- /dev/null +++ b/EnumerableAsyncProcessor.Pipeline/Modules/BuildBenchmarkProjectsModule.cs @@ -0,0 +1,37 @@ +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.DotNet.Options; +using ModularPipelines.Git.Extensions; +using ModularPipelines.Models; +using ModularPipelines.Modules; +using ModularPipelines.Options; + +namespace EnumerableAsyncProcessor.Pipeline.Modules; + +public class BuildBenchmarkProjectsModule : Module> +{ + protected override async Task?> ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken) + { + var results = new List(); + var executionOptions = new CommandExecutionOptions + { + ThrowOnNonZeroExitCode = true, + }; + + foreach (var benchmarkProjectFile in context + .Git().RootDirectory + .GetFiles(file => file.Path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) + && file.Path.Contains("Benchmarks", StringComparison.OrdinalIgnoreCase))) + { + results.Add(await context.DotNet().Build(new DotNetBuildOptions + { + ProjectSolution = benchmarkProjectFile.Path, + Configuration = "Release", + }, executionOptions: executionOptions, cancellationToken: cancellationToken).ConfigureAwait(false)); + } + + return results; + } +} diff --git a/EnumerableAsyncProcessor.Pipeline/Modules/PackProjectsModule.cs b/EnumerableAsyncProcessor.Pipeline/Modules/PackProjectsModule.cs index 2777d1b..7485706 100644 --- a/EnumerableAsyncProcessor.Pipeline/Modules/PackProjectsModule.cs +++ b/EnumerableAsyncProcessor.Pipeline/Modules/PackProjectsModule.cs @@ -13,6 +13,7 @@ namespace EnumerableAsyncProcessor.Pipeline.Modules; [DependsOn] [DependsOn] [DependsOn] +[DependsOn] [DependsOn] public class PackProjectsModule : Module> { @@ -49,6 +50,7 @@ private bool GetProjectsPredicate(File file, IModuleContext context) } if (path.Contains("Tests", StringComparison.OrdinalIgnoreCase) + || path.Contains("Benchmarks", StringComparison.OrdinalIgnoreCase) || path.Contains("Pipeline", StringComparison.OrdinalIgnoreCase) || path.Contains("Example", StringComparison.OrdinalIgnoreCase)) { diff --git a/EnumerableAsyncProcessor.Pipeline/Program.cs b/EnumerableAsyncProcessor.Pipeline/Program.cs index 3b28668..b707f98 100644 --- a/EnumerableAsyncProcessor.Pipeline/Program.cs +++ b/EnumerableAsyncProcessor.Pipeline/Program.cs @@ -26,7 +26,8 @@ builder.AddModule(); } -builder.AddModule() +builder.AddModule() + .AddModule() .AddModule() .AddModule() .AddModule() diff --git a/README.md b/README.md index 9328c04..179f1bd 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ Process Multiple Asynchronous Tasks in Various Ways - One at a time / Batched / Install via Nuget `Install-Package EnumerableAsyncProcessor` +## Performance benchmarks + +See the [benchmark guide](benchmarks/README.md) to run the BenchmarkDotNet suite, filter scenarios, and compare revisions. + ### Supported frameworks Version 4 requires .NET 8 or later. The package targets and tests `net8.0`, `net9.0`, and `net10.0`. diff --git a/TomLonghurst.EnumerableAsyncProcessor.sln b/TomLonghurst.EnumerableAsyncProcessor.sln index 58ca08f..7cade2f 100644 --- a/TomLonghurst.EnumerableAsyncProcessor.sln +++ b/TomLonghurst.EnumerableAsyncProcessor.sln @@ -8,6 +8,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnumerableAsyncProcessor.Ex EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnumerableAsyncProcessor.Pipeline", "EnumerableAsyncProcessor.Pipeline\EnumerableAsyncProcessor.Pipeline.csproj", "{522C01BC-E86F-4C53-BD96-1EBA4129CE53}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnumerableAsyncProcessor.Benchmarks", "benchmarks\EnumerableAsyncProcessor.Benchmarks.csproj", "{B6D06B46-12E2-4D85-BACE-8BCE0A3A4EB8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -30,5 +32,9 @@ Global {522C01BC-E86F-4C53-BD96-1EBA4129CE53}.Debug|Any CPU.Build.0 = Debug|Any CPU {522C01BC-E86F-4C53-BD96-1EBA4129CE53}.Release|Any CPU.ActiveCfg = Release|Any CPU {522C01BC-E86F-4C53-BD96-1EBA4129CE53}.Release|Any CPU.Build.0 = Release|Any CPU + {B6D06B46-12E2-4D85-BACE-8BCE0A3A4EB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B6D06B46-12E2-4D85-BACE-8BCE0A3A4EB8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B6D06B46-12E2-4D85-BACE-8BCE0A3A4EB8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B6D06B46-12E2-4D85-BACE-8BCE0A3A4EB8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/benchmarks/EnumerableAsyncProcessor.Benchmarks.csproj b/benchmarks/EnumerableAsyncProcessor.Benchmarks.csproj new file mode 100644 index 0000000..0440bb2 --- /dev/null +++ b/benchmarks/EnumerableAsyncProcessor.Benchmarks.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + diff --git a/benchmarks/ProcessorBenchmarks.cs b/benchmarks/ProcessorBenchmarks.cs new file mode 100644 index 0000000..b851844 --- /dev/null +++ b/benchmarks/ProcessorBenchmarks.cs @@ -0,0 +1,134 @@ +using BenchmarkDotNet.Attributes; +using EnumerableAsyncProcessor.Extensions; + +namespace EnumerableAsyncProcessor.Benchmarks; + +[MemoryDiagnoser] +public class ProcessorBenchmarks +{ + private const int Concurrency = 64; + private int[] _items = null!; + private Func _processItemAsync = null!; + private Func> _transformItemAsync = null!; + + public enum SelectorWorkload + { + CompletedTask, + TaskYield + } + + [Params(1_000, 10_000, 100_000)] + public int ItemCount { get; set; } + + [Params(SelectorWorkload.CompletedTask, SelectorWorkload.TaskYield)] + public SelectorWorkload Workload { get; set; } + + [GlobalSetup] + public void Setup() + { + _items = Enumerable.Range(0, ItemCount).ToArray(); + _processItemAsync = Workload == SelectorWorkload.CompletedTask + ? ProcessCompletedItemAsync + : ProcessWithYieldAsync; + _transformItemAsync = Workload == SelectorWorkload.CompletedTask + ? TransformCompletedItemAsync + : TransformWithYieldAsync; + } + + [Benchmark(Baseline = true)] + public async Task UnboundedParallel() + { + await using var processor = _items + .ForEachAsync(_processItemAsync) + .ProcessInParallel(); + + await processor.WaitAsync(); + } + + [Benchmark] + public async Task ThrottledParallel() + { + await using var processor = _items + .ForEachAsync(_processItemAsync) + .ProcessInParallel(maxConcurrency: Concurrency); + + await processor.WaitAsync(); + } + + [Benchmark] + public async Task RateLimitedParallel() + { + await using var processor = _items + .ForEachAsync(_processItemAsync) + .ProcessInParallel(Concurrency); + + await processor.WaitAsync(); + } + + [Benchmark] + public async Task TimedRateLimitedParallel() + { + await using var processor = _items + .ForEachAsync(_processItemAsync) + .ProcessInParallel(Concurrency, TimeSpan.Zero); + + await processor.WaitAsync(); + } + + [Benchmark] + public async Task Batch() + { + await using var processor = _items + .ForEachAsync(_processItemAsync) + .ProcessInBatches(Concurrency); + + await processor.WaitAsync(); + } + + [Benchmark] + public async Task OneAtATime() + { + await using var processor = _items + .ForEachAsync(_processItemAsync) + .ProcessOneAtATime(); + + await processor.WaitAsync(); + } + + [Benchmark] + public async Task ResultStreaming() + { + await using var processor = _items + .SelectAsync(_transformItemAsync) + .ProcessInParallel(maxConcurrency: Concurrency); + + var checksum = 0; + await foreach (var result in processor.GetResultsAsyncEnumerable()) + { + checksum = unchecked(checksum + result); + } + + return checksum; + } + + private static Task ProcessCompletedItemAsync(int _) + { + return Task.CompletedTask; + } + + private static Task TransformCompletedItemAsync(int item) + { + return Task.FromResult(item); + } + + private static async Task ProcessWithYieldAsync(int _) + { + await Task.Yield(); + } + + private static async Task TransformWithYieldAsync(int item) + { + await Task.Yield(); + return item; + } +} diff --git a/benchmarks/Program.cs b/benchmarks/Program.cs new file mode 100644 index 0000000..c9a0467 --- /dev/null +++ b/benchmarks/Program.cs @@ -0,0 +1,3 @@ +using BenchmarkDotNet.Running; + +BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..a03b8a8 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,26 @@ +# Benchmarks + +The suite measures processor coordination overhead and allocations with completed-task and `Task.Yield` selectors at 1,000, 10,000, and 100,000 items. +The timed scenario uses a zero-duration window so the results isolate coordination overhead rather than wall-clock waiting. + +Run every benchmark from the repository root: + +```shell +dotnet run -c Release --project benchmarks +``` + +`BenchmarkSwitcher` forwards BenchmarkDotNet command-line options. List or filter cases before a focused comparison: + +```shell +dotnet run -c Release --project benchmarks -- --list flat +dotnet run -c Release --project benchmarks -- --filter "*ThrottledParallel*" +``` + +`UnboundedParallel` is the in-process baseline for ratio columns. To compare code revisions, run the same filter in separate clean worktrees and give each run a distinct artifacts directory: + +```shell +dotnet run -c Release --project benchmarks -- --filter "*ResultStreaming*" --artifacts artifacts/baseline +dotnet run -c Release --project benchmarks -- --filter "*ResultStreaming*" --artifacts artifacts/candidate +``` + +Use Release builds on the same idle machine and compare the generated Markdown or CSV reports under each artifacts directory.