From 1ccd05ec3cfbd506bd5b702d80f2f51162e97d58 Mon Sep 17 00:00:00 2001 From: antonfirsov Date: Fri, 10 Apr 2026 00:14:23 +0200 Subject: [PATCH] proper ProcessorThroughputTest --- .../ImageSharp.Tests.ProfilingSandbox.csproj | 1 - .../ParallelProcessingStress.Experiment.cs | 88 --------- ...ngStress.cs => ProcessorThroughputTest.cs} | 168 +++++++++--------- .../Program.cs | 77 +++----- 4 files changed, 103 insertions(+), 231 deletions(-) delete mode 100644 tests/ImageSharp.Tests.ProfilingSandbox/ParallelProcessingStress.Experiment.cs rename tests/ImageSharp.Tests.ProfilingSandbox/{ParallelProcessingStress.cs => ProcessorThroughputTest.cs} (55%) diff --git a/tests/ImageSharp.Tests.ProfilingSandbox/ImageSharp.Tests.ProfilingSandbox.csproj b/tests/ImageSharp.Tests.ProfilingSandbox/ImageSharp.Tests.ProfilingSandbox.csproj index bc52610d2c..f3aa910b96 100644 --- a/tests/ImageSharp.Tests.ProfilingSandbox/ImageSharp.Tests.ProfilingSandbox.csproj +++ b/tests/ImageSharp.Tests.ProfilingSandbox/ImageSharp.Tests.ProfilingSandbox.csproj @@ -8,7 +8,6 @@ false SixLabors.ImageSharp.Tests.ProfilingSandbox win-x64 - SixLabors.ImageSharp.Tests.ProfilingSandbox.Program false false diff --git a/tests/ImageSharp.Tests.ProfilingSandbox/ParallelProcessingStress.Experiment.cs b/tests/ImageSharp.Tests.ProfilingSandbox/ParallelProcessingStress.Experiment.cs deleted file mode 100644 index ffe09a35b3..0000000000 --- a/tests/ImageSharp.Tests.ProfilingSandbox/ParallelProcessingStress.Experiment.cs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -using CommandLine; -using CommandLine.Text; - -namespace SixLabors.ImageSharp.Tests.ProfilingSandbox; - -public partial class ParallelProcessingStress -{ - public static void RunExperiment(string[] args) - { - ExperimentOptions options = null; - using Parser parser = new(settings => settings.CaseInsensitiveEnumValues = true); - ParserResult result = parser.ParseArguments(args).WithParsed(o => options = o); - if (options == null) - { - Console.WriteLine(HelpText.RenderUsageText(result)); - return; - } - - RunExperiment(options.Method, options.Seconds, options.IterationCount); - } - - public static void RunExperiment(Method method, int seconds = 5, int times = 5) - { - // Warmup - Console.WriteLine("Warming up..."); - CommandLineOptions warmupOptions = new() { Method = method, Seconds = 1 }; - warmupOptions.Normalize(); - new ParallelProcessingStress(warmupOptions).Run(); - - // Outer loop: run inner loop for each parallelism level - List<(int Parallelism, double AvgMpxPerSecPerCpu)> results = new(); - - foreach (int parallelism in ParallelismLevels()) - { - Console.WriteLine($"\nRunning {method} with ProcessorParallelism={parallelism} ({times}x {seconds}s)..."); - - double totalMpxPerSecPerCpu = 0; - for (int i = 0; i < times; i++) - { - CommandLineOptions options = new() { Method = method, ProcessorParallelism = parallelism, Seconds = seconds }; - options.Normalize(); - Stats stats = new ParallelProcessingStress(options).Run(); - totalMpxPerSecPerCpu += stats.MegapixelsPerSecPerCpu; - } - - results.Add((parallelism, totalMpxPerSecPerCpu / times)); - } - - // Print results as markdown table - Console.WriteLine(); - Console.WriteLine("| ProcessorParallelism | MegapixelsPerSecPerCpu |"); - Console.WriteLine("|---------------------:|-----------------------:|"); - foreach ((int parallelism, double avg) in results) - { - Console.WriteLine($"| {parallelism,20} | {avg,22:f3} |"); - } - } - - private sealed class ExperimentOptions - { - [Option('m', "method", Required = false, Default = Method.Edges, HelpText = "The stress test method to run (Edges, Crop)")] - public Method Method { get; set; } = Method.Edges; - - [Option('s', "seconds", Required = false, Default = 5, HelpText = "Duration of each run in seconds")] - public int Seconds { get; set; } = 5; - - [Option('i', "iterations", Required = false, Default = 5, HelpText = "Number of runs per parallelism level")] - public int IterationCount { get; set; } = 5; - } - - private static IEnumerable ParallelismLevels() - { - int cpuCount = Environment.ProcessorCount; - for (int p = 1; p <= cpuCount; p *= 2) - { - yield return p; - } - - // When cpuCount is not a power of two, append it as the final step - if ((cpuCount & (cpuCount - 1)) != 0) - { - yield return cpuCount; - } - } -} diff --git a/tests/ImageSharp.Tests.ProfilingSandbox/ParallelProcessingStress.cs b/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputTest.cs similarity index 55% rename from tests/ImageSharp.Tests.ProfilingSandbox/ParallelProcessingStress.cs rename to tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputTest.cs index f5bda66461..d94aaf1ba5 100644 --- a/tests/ImageSharp.Tests.ProfilingSandbox/ParallelProcessingStress.cs +++ b/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputTest.cs @@ -2,8 +2,6 @@ // Licensed under the Six Labors Split License. using System.Diagnostics; -using System.Globalization; -using System.Text; using CommandLine; using CommandLine.Text; using SixLabors.ImageSharp.PixelFormats; @@ -11,13 +9,23 @@ using SixLabors.ImageSharp.Processing; namespace SixLabors.ImageSharp.Tests.ProfilingSandbox; -public sealed partial class ParallelProcessingStress +public sealed class ProcessorThroughputTest { + private const ulong CountingUnit = 1; private CommandLineOptions options; private Configuration configuration; - private ulong totalKiloPixels; + private ulong totalPixelsInUnit; - public static void Run(string[] args) + private ProcessorThroughputTest(CommandLineOptions options) + { + this.options = options; + this.configuration = Configuration.Default.Clone(); + this.configuration.MaxDegreeOfParallelism = options.ProcessorParallelism > 0 + ? options.ProcessorParallelism + : Environment.ProcessorCount; + } + + public static Task RunAsync(string[] args) { CommandLineOptions options = null; if (args.Length > 0) @@ -25,60 +33,91 @@ public sealed partial class ParallelProcessingStress options = CommandLineOptions.Parse(args); if (options == null) { - return; + return Task.CompletedTask; } } options ??= new CommandLineOptions(); - ParallelProcessingStress stress = new(options.Normalize()); - stress.Run(); - } - - private ParallelProcessingStress(CommandLineOptions options) - { - this.options = options; - this.configuration = Configuration.Default.Clone(); - this.configuration.MaxDegreeOfParallelism = options.ProcessorParallelism > 0 - ? options.ProcessorParallelism - : Environment.ProcessorCount; + return new ProcessorThroughputTest(options.Normalize()) + .RunAsync(); } - private Stats Run() + private async Task RunAsync() { - ParallelOptions systemOptions = new() { MaxDegreeOfParallelism = this.options.SystemParallelism }; + SemaphoreSlim semaphore = new(this.options.ConcurrentRequests); + Console.WriteLine(this.options.Method); Func action = this.options.Method switch { Method.Crop => this.Crop, _ => this.DetectEdges, }; + + Console.WriteLine(this.options); Console.WriteLine($"Running {this.options.Method} for {this.options.Seconds} seconds ..."); - Stopwatch stopwatch = Stopwatch.StartNew(); TimeSpan runFor = TimeSpan.FromSeconds(this.options.Seconds); - Parallel.ForEach(InfiniteSequence(), systemOptions, (_, state) => + + // inFlight starts at 1 to represent the dispatch loop itself + int inFlight = 1; + TaskCompletionSource drainTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + + Stopwatch stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < runFor && !drainTcs.Task.IsCompleted) { - ulong kiloPixels = (ulong)action() / 1000; - Interlocked.Add(ref this.totalKiloPixels, kiloPixels); + await semaphore.WaitAsync(); if (stopwatch.Elapsed >= runFor) { - state.Stop(); + semaphore.Release(); + break; } - }); - stopwatch.Stop(); - double totalMegaPixels = this.totalKiloPixels / 1000.0; - Stats stats = new(stopwatch.ElapsedMilliseconds, totalMegaPixels, systemOptions.MaxDegreeOfParallelism); - Console.WriteLine(stats.GetMarkdown()); - return stats; - } + Interlocked.Increment(ref inFlight); - private static IEnumerable InfiniteSequence() - { - long i = 0; - while (true) + _ = ProcessImage(); + + async Task ProcessImage() + { + try + { + if (stopwatch.Elapsed >= runFor || drainTcs.Task.IsCompleted) + { + return; + } + + await Task.Yield(); // "emulate IO", i.e., make sure the processing code is async + ulong pixels = (ulong)action() / CountingUnit; + Interlocked.Add(ref this.totalPixelsInUnit, pixels); + } + catch (Exception ex) + { + Console.WriteLine(ex); + drainTcs.TrySetException(ex); + } + finally + { + semaphore.Release(); + if (Interlocked.Decrement(ref inFlight) == 0) + { + drainTcs.TrySetResult(); + } + } + } + } + + // Release the dispatch loop's own count; if no work is in flight, this completes immediately + if (Interlocked.Decrement(ref inFlight) == 0) { - yield return i++; + drainTcs.TrySetResult(); } + + await drainTcs.Task; + stopwatch.Stop(); + + double totalMegaPixels = this.totalPixelsInUnit * (double)CountingUnit / 1_000_000.0; + double totalSeconds = stopwatch.ElapsedMilliseconds / 1000.0; + double megapixelsPerSec = totalMegaPixels / totalSeconds; + Console.WriteLine($"TotalSeconds: {totalSeconds:F2}"); + Console.WriteLine($"MegaPixelsPerSec: {megapixelsPerSec:F2}"); } private int DetectEdges() @@ -97,50 +136,7 @@ public sealed partial class ParallelProcessingStress return image.Width * image.Height; } - private sealed record Stats - { - public double TotalSeconds { get; } - - public double TotalMegapixels { get; } - - public double MegapixelsPerSec { get; } - - public double MegapixelsPerSecPerCpu { get; } - - public Stats(long elapsedMilliseconds, double totalMegapixels, int cpuCount) - { - this.TotalMegapixels = totalMegapixels; - this.TotalSeconds = elapsedMilliseconds / 1000.0; - this.MegapixelsPerSec = totalMegapixels / this.TotalSeconds; - this.MegapixelsPerSecPerCpu = this.MegapixelsPerSec / cpuCount; - } - - public string GetMarkdown() - { - StringBuilder bld = new(); - bld.AppendLine( - CultureInfo.InvariantCulture, - $"| {nameof(this.TotalSeconds)} | {nameof(this.MegapixelsPerSec)} | {nameof(this.MegapixelsPerSecPerCpu)} |"); - bld.AppendLine( - CultureInfo.InvariantCulture, - $"| {L(nameof(this.TotalSeconds))} | {L(nameof(this.MegapixelsPerSec))} | {L(nameof(this.MegapixelsPerSecPerCpu))} |"); - - bld.Append("| "); - bld.AppendFormat(CultureInfo.InvariantCulture, F(nameof(this.TotalSeconds)), this.TotalSeconds); - bld.Append(" | "); - bld.AppendFormat(CultureInfo.InvariantCulture, F(nameof(this.MegapixelsPerSec)), this.MegapixelsPerSec); - bld.Append(" | "); - bld.AppendFormat(CultureInfo.InvariantCulture, F(nameof(this.MegapixelsPerSecPerCpu)), this.MegapixelsPerSecPerCpu); - bld.AppendLine(" |"); - - return bld.ToString(); - - static string L(string header) => new('-', header.Length); - static string F(string column) => $"{{0,{column.Length}:f3}}"; - } - } - - public enum Method + private enum Method { Edges, Crop @@ -154,8 +150,8 @@ public sealed partial class ParallelProcessingStress [Option('p', "processor-parallelism", Required = false, Default = -1, HelpText = "Level of parallelism for the image processor")] public int ProcessorParallelism { get; set; } = -1; - [Option('t', "system-parallelism", Required = false, Default = -1, HelpText = "Level of parallelism for the outer loop")] - public int SystemParallelism { get; set; } = -1; + [Option('c', "concurrent-requests", Required = false, Default = -1, HelpText = "Number of concurrent in-flight requests")] + public int ConcurrentRequests { get; set; } = -1; [Option('w', "width", Required = false, Default = 4000, HelpText = "Width of the test image")] public int Width { get; set; } = 4000; @@ -167,10 +163,10 @@ public sealed partial class ParallelProcessingStress public int Seconds { get; set; } = 5; public override string ToString() => string.Join( - Environment.NewLine, + "|", $"method: {this.Method}", $"processor-parallelism: {this.ProcessorParallelism}", - $"system-parallelism: {this.SystemParallelism}", + $"concurrent-requests: {this.ConcurrentRequests}", $"width: {this.Width}", $"height: {this.Height}", $"seconds: {this.Seconds}"); @@ -182,9 +178,9 @@ public sealed partial class ParallelProcessingStress this.ProcessorParallelism = Environment.ProcessorCount; } - if (this.SystemParallelism < 0) + if (this.ConcurrentRequests < 0) { - this.SystemParallelism = Environment.ProcessorCount; + this.ConcurrentRequests = Environment.ProcessorCount; } return this; diff --git a/tests/ImageSharp.Tests.ProfilingSandbox/Program.cs b/tests/ImageSharp.Tests.ProfilingSandbox/Program.cs index 0a1c9b80b1..8ca57d0c3a 100644 --- a/tests/ImageSharp.Tests.ProfilingSandbox/Program.cs +++ b/tests/ImageSharp.Tests.ProfilingSandbox/Program.cs @@ -1,73 +1,38 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -using System.Reflection; using SixLabors.ImageSharp.Tests.PixelFormats.PixelOperations; using SixLabors.ImageSharp.Tests.ProfilingBenchmarks; +using SixLabors.ImageSharp.Tests.ProfilingSandbox; using Xunit.Abstractions; // in this file, comments are used for disabling stuff for local execution #pragma warning disable SA1515 #pragma warning disable SA1512 -namespace SixLabors.ImageSharp.Tests.ProfilingSandbox; +// LoadResizeSaveParallelMemoryStress.Run(args); +// ParallelProcessingStress.RunExperiment(args); +// ParallelProcessingStress.Run(args); +await ProcessorThroughputTest.RunAsync(args); -public class Program -{ - private sealed class ConsoleOutput : ITestOutputHelper - { - public void WriteLine(string message) => Console.WriteLine(message); - - public void WriteLine(string format, params object[] args) => Console.WriteLine(format, args); - } - - /// - /// The main entry point. Useful for executing benchmarks and performance unit tests manually, - /// when the IDE test runners lack some of the functionality. Eg.: it's not possible to run JetBrains memory profiler for unit tests. - /// - /// - /// The arguments to pass to the program. - /// - public static void Main(string[] args) - { - try - { - // LoadResizeSaveParallelMemoryStress.Run(args); - ParallelProcessingStress.RunExperiment(args); - // ParallelProcessingStress.Run(args); - } - catch (Exception ex) - { - Console.WriteLine(ex); - } +// RunToVector4ProfilingTest(); +// RunResizeProfilingTest(); - // RunToVector4ProfilingTest(); - // RunResizeProfilingTest(); - } - - private static Version GetNetCoreVersion() - { - Assembly assembly = typeof(System.Runtime.GCSettings).GetTypeInfo().Assembly; - Console.WriteLine(assembly.Location); - string[] assemblyPath = assembly.Location.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); - int netCoreAppIndex = Array.IndexOf(assemblyPath, "Microsoft.NETCore.App"); - if (netCoreAppIndex > 0 && netCoreAppIndex < assemblyPath.Length - 2) - { - return Version.Parse(assemblyPath[netCoreAppIndex + 1]); - } +static void RunResizeProfilingTest() +{ + ResizeProfilingBenchmarks test = new(new ConsoleOutput()); + test.ResizeBicubic(4000, 4000); +} - return null; - } +static void RunToVector4ProfilingTest() +{ + PixelOperationsTests.Rgba32_OperationsTests tests = new(new ConsoleOutput()); + tests.Benchmark_ToVector4(); +} - private static void RunResizeProfilingTest() - { - ResizeProfilingBenchmarks test = new(new ConsoleOutput()); - test.ResizeBicubic(4000, 4000); - } +sealed class ConsoleOutput : ITestOutputHelper +{ + public void WriteLine(string message) => Console.WriteLine(message); - private static void RunToVector4ProfilingTest() - { - PixelOperationsTests.Rgba32_OperationsTests tests = new(new ConsoleOutput()); - tests.Benchmark_ToVector4(); - } + public void WriteLine(string format, params object[] args) => Console.WriteLine(format, args); }