From e616f60cea6ea600201d6293f9620ad07fd91611 Mon Sep 17 00:00:00 2001 From: antonfirsov Date: Sat, 4 Apr 2026 19:23:02 +0200 Subject: [PATCH 01/12] add parallel processing benchmarks --- .../Processing/ParallelProcessing.cs | 56 +++++ .../ParallelProcessingStress.cs | 202 ++++++++++++++++++ .../Program.cs | 8 +- 3 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 tests/ImageSharp.Benchmarks/Processing/ParallelProcessing.cs create mode 100644 tests/ImageSharp.Tests.ProfilingSandbox/ParallelProcessingStress.cs diff --git a/tests/ImageSharp.Benchmarks/Processing/ParallelProcessing.cs b/tests/ImageSharp.Benchmarks/Processing/ParallelProcessing.cs new file mode 100644 index 0000000000..14635136a5 --- /dev/null +++ b/tests/ImageSharp.Benchmarks/Processing/ParallelProcessing.cs @@ -0,0 +1,56 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using BenchmarkDotNet.Attributes; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Tests; + +namespace SixLabors.ImageSharp.Benchmarks; + +public class ParallelProcessing +{ + private Image image; + private Configuration configuration; + + public static IEnumerable MaxDegreeOfParallelismValues() + { + int processorCount = Environment.ProcessorCount; + for (int p = 1; p <= processorCount; p *= 2) + { + yield return p; + } + + if ((processorCount & (processorCount - 1)) != 0) + { + yield return processorCount; + } + } + + [ParamsSource(nameof(MaxDegreeOfParallelismValues))] + public int MaxDegreeOfParallelism { get; set; } + + [GlobalSetup] + public void Setup() + { + this.image = new Image(2048, 2048); + this.configuration = Configuration.Default.Clone(); + this.configuration.MaxDegreeOfParallelism = this.MaxDegreeOfParallelism; + } + + [Benchmark] + public void DetectEdges() => this.image.Mutate(this.configuration, x => x.DetectEdges()); + + [Benchmark] + public void Crop() + { + Rectangle bounds = this.image.Bounds; + bounds = new Rectangle(1, 1, bounds.Width - 2, bounds.Height - 2); + this.image + .Clone(this.configuration, x => x.Crop(bounds)) + .Dispose(); + } + + [GlobalCleanup] + public void Cleanup() => this.image.Dispose(); +} diff --git a/tests/ImageSharp.Tests.ProfilingSandbox/ParallelProcessingStress.cs b/tests/ImageSharp.Tests.ProfilingSandbox/ParallelProcessingStress.cs new file mode 100644 index 0000000000..a9bfda9c36 --- /dev/null +++ b/tests/ImageSharp.Tests.ProfilingSandbox/ParallelProcessingStress.cs @@ -0,0 +1,202 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Diagnostics; +using System.Text; +using CommandLine; +using CommandLine.Text; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace SixLabors.ImageSharp.Tests.ProfilingSandbox; + +public class ParallelProcessingStress +{ + private CommandLineOptions options; + private Configuration configuration; + private ulong totalKiloPixels; + + public static Stats Run(string[] args) + { + CommandLineOptions options = null; + if (args.Length > 0) + { + options = CommandLineOptions.Parse(args); + if (options == null) + { + return null; + } + } + + options ??= new CommandLineOptions(); + ParallelProcessingStress stress = new(options.Normalize()); + return stress.Run(); + } + + private ParallelProcessingStress(CommandLineOptions options) + { + this.options = options; + this.configuration = Configuration.Default.Clone(); + this.configuration.MaxDegreeOfParallelism = options.ProcessorParallelism > 0 + ? options.ProcessorParallelism + : Environment.ProcessorCount; + } + + private Stats Run() + { + ParallelOptions systemOptions = new() { MaxDegreeOfParallelism = this.options.SystemParallelism }; + Func action = this.options.Method switch + { + Method.Crop => this.Crop, + _ => this.DetectEdges, + }; + 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) => + { + ulong kiloPixels = (ulong)action() / 1000; + Interlocked.Add(ref this.totalKiloPixels, kiloPixels); + + if (stopwatch.Elapsed >= runFor) + { + state.Stop(); + } + }); + stopwatch.Stop(); + + double totalMegaPixels = this.totalKiloPixels / 1000.0; + Stats stats = new(stopwatch, totalMegaPixels, systemOptions.MaxDegreeOfParallelism); + Console.WriteLine(stats.GetMarkdown()); + return stats; + } + + private static IEnumerable InfiniteSequence() + { + long i = 0; + while (true) + { + yield return i++; + } + } + + private int DetectEdges() + { + using Image image = new(this.options.Width, this.options.Height); + image.Mutate(this.configuration, x => x.DetectEdges()); + return image.Width * image.Height; + } + + private int Crop() + { + using Image image = new(this.options.Width, this.options.Height); + Rectangle bounds = image.Bounds; + bounds = new Rectangle(1, 1, bounds.Width - 2, bounds.Height - 2); + image.Clone(this.configuration, x => x.Crop(bounds)).Dispose(); + return image.Width * image.Height; + } + + public record Stats + { + public double TotalSeconds { get; } + + public double TotalMegapixels { get; } + + public double MegapixelsPerSec { get; } + + public double MegapixelsPerSecPerCpu { get; } + + public Stats(Stopwatch sw, double totalMegapixels, int cpuCount) + { + this.TotalMegapixels = totalMegapixels; + this.TotalSeconds = sw.ElapsedMilliseconds / 1000.0; + this.MegapixelsPerSec = totalMegapixels / this.TotalSeconds; + this.MegapixelsPerSecPerCpu = this.MegapixelsPerSec / cpuCount; + } + + public string GetMarkdown() + { + StringBuilder bld = new(); + bld.AppendLine($"| {nameof(this.TotalSeconds)} | {nameof(this.MegapixelsPerSec)} | {nameof(this.MegapixelsPerSecPerCpu)} |"); + bld.AppendLine( + $"| {L(nameof(this.TotalSeconds))} | {L(nameof(this.MegapixelsPerSec))} | {L(nameof(this.MegapixelsPerSecPerCpu))} |"); + + bld.Append("| "); + bld.AppendFormat(F(nameof(this.TotalSeconds)), this.TotalSeconds); + bld.Append(" | "); + bld.AppendFormat(F(nameof(this.MegapixelsPerSec)), this.MegapixelsPerSec); + bld.Append(" | "); + bld.AppendFormat(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}}"; + } + } + + private enum Method { Edges, Crop } + + private class CommandLineOptions + { + [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('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('w', "width", Required = false, Default = 4000, HelpText = "Width of the test image")] + public int Width { get; set; } = 4000; + + [Option('h', "height", Required = false, Default = 4000, HelpText = "Height of the test image")] + public int Height { get; set; } = 4000; + + [Option('s', "seconds", Required = false, Default = 5, HelpText = "Duration of the stress test in seconds")] + 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}", + $"width: {this.Width}", + $"height: {this.Height}", + $"seconds: {this.Seconds}"); + + public CommandLineOptions Normalize() + { + if (this.ProcessorParallelism < 0) + { + this.ProcessorParallelism = Environment.ProcessorCount; + } + + if (this.SystemParallelism < 0) + { + this.SystemParallelism = Environment.ProcessorCount; + } + + return this; + } + + public static CommandLineOptions Parse(string[] args) + { + CommandLineOptions result = null; + using Parser parser = new(settings => settings.CaseInsensitiveEnumValues = true); + ParserResult parserResult = parser.ParseArguments(args).WithParsed(o => + { + result = o; + }); + + if (result == null) + { + Console.WriteLine(HelpText.RenderUsageText(parserResult)); + } + + return result; + } + } +} \ No newline at end of file diff --git a/tests/ImageSharp.Tests.ProfilingSandbox/Program.cs b/tests/ImageSharp.Tests.ProfilingSandbox/Program.cs index 8ba862560b..3245198734 100644 --- a/tests/ImageSharp.Tests.ProfilingSandbox/Program.cs +++ b/tests/ImageSharp.Tests.ProfilingSandbox/Program.cs @@ -32,20 +32,16 @@ public static void Main(string[] args) { try { - LoadResizeSaveParallelMemoryStress.Run(args); + // LoadResizeSaveParallelMemoryStress.Run(args); + ParallelProcessingStress.Run(args); } catch (Exception ex) { Console.WriteLine(ex); } - // RunJpegEncoderProfilingTests(); - // RunJpegColorProfilingTests(); - // RunDecodeJpegProfilingTests(); // RunToVector4ProfilingTest(); // RunResizeProfilingTest(); - - // Console.ReadLine(); } private static Version GetNetCoreVersion() From 57ebbff98effbc54545b9d278b000efb3b4ee216 Mon Sep 17 00:00:00 2001 From: antonfirsov Date: Sat, 4 Apr 2026 21:44:03 +0200 Subject: [PATCH 02/12] introduce RunExperiment and fix warnings in test code --- tests/Directory.Build.targets | 2 +- .../LoadResizeSaveStressRunner.cs | 18 ++-- .../LoadResizeSaveParallelMemoryStress.cs | 13 +-- .../ParallelProcessingStress.Experiment.cs | 88 +++++++++++++++++++ .../ParallelProcessingStress.cs | 38 ++++---- .../Program.cs | 5 +- .../ReferenceCodecs/MagickReferenceDecoder.cs | 14 +-- 7 files changed, 140 insertions(+), 38 deletions(-) create mode 100644 tests/ImageSharp.Tests.ProfilingSandbox/ParallelProcessingStress.Experiment.cs diff --git a/tests/Directory.Build.targets b/tests/Directory.Build.targets index 8c88ff647d..6b25509ed8 100644 --- a/tests/Directory.Build.targets +++ b/tests/Directory.Build.targets @@ -24,7 +24,7 @@ Do not update to 14+ yet. There's differnce in how the BMP decoder handles rounding in 16 bit images. See https://github.com/ImageMagick/ImageMagick/commit/27a0a9c37f18af9c8d823a3ea076f600843b553c --> - + diff --git a/tests/ImageSharp.Benchmarks/LoadResizeSave/LoadResizeSaveStressRunner.cs b/tests/ImageSharp.Benchmarks/LoadResizeSave/LoadResizeSaveStressRunner.cs index f8bf19d576..804a60e2cc 100644 --- a/tests/ImageSharp.Benchmarks/LoadResizeSave/LoadResizeSaveStressRunner.cs +++ b/tests/ImageSharp.Benchmarks/LoadResizeSave/LoadResizeSaveStressRunner.cs @@ -6,6 +6,7 @@ using System.Drawing.Imaging; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Runtime.Versioning; using ImageMagick; using PhotoSauce.MagicScaler; using SixLabors.ImageSharp.Formats; @@ -27,7 +28,8 @@ public enum JpegKind Any = Baseline | Progressive } -public class LoadResizeSaveStressRunner +[SupportedOSPlatform("windows")] +public sealed class LoadResizeSaveStressRunner { private const int Quality = 75; @@ -158,7 +160,7 @@ private string OutputPath(string inputPath, [CallerMemberName] string postfix = this.outputDirectory, Path.GetFileNameWithoutExtension(inputPath) + "-" + postfix + Path.GetExtension(inputPath)); - private (int Width, int Height) ScaledSize(int inWidth, int inHeight, int outSize) + private static (int Width, int Height) ScaledSize(int inWidth, int inHeight, int outSize) { int width, height; if (inWidth > inHeight) @@ -180,7 +182,7 @@ public void SystemDrawingResize(string input) using SystemDrawingImage image = SystemDrawingImage.FromFile(input, true); this.LogImageProcessed(image.Width, image.Height); - (int width, int height) = this.ScaledSize(image.Width, image.Height, this.ThumbnailSize); + (int width, int height) = ScaledSize(image.Width, image.Height, this.ThumbnailSize); Bitmap resized = new(width, height); using Graphics graphics = Graphics.FromImage(resized); using ImageAttributes attributes = new(); @@ -248,10 +250,10 @@ public async Task ImageSharpResizeAsync(string input) public void MagickResize(string input) { using MagickImage image = new(input); - this.LogImageProcessed(image.Width, image.Height); + this.LogImageProcessed((int)image.Width, (int)image.Height); // Resize it to fit a 150x150 square - image.Resize(this.ThumbnailSize, this.ThumbnailSize); + image.Resize((uint)this.ThumbnailSize, (uint)this.ThumbnailSize); // Reduce the size of the file image.Strip(); @@ -282,7 +284,7 @@ public void SkiaCanvasResize(string input) { using SKBitmap original = SKBitmap.Decode(input); this.LogImageProcessed(original.Width, original.Height); - (int width, int height) = this.ScaledSize(original.Width, original.Height, this.ThumbnailSize); + (int width, int height) = ScaledSize(original.Width, original.Height, this.ThumbnailSize); using SKSurface surface = SKSurface.Create(new SKImageInfo(width, height, original.ColorType, original.AlphaType)); using SKPaint paint = new() { FilterQuality = SKFilterQuality.High }; SKCanvas canvas = surface.Canvas; @@ -300,7 +302,7 @@ public void SkiaBitmapResize(string input) { using SKBitmap original = SKBitmap.Decode(input); this.LogImageProcessed(original.Width, original.Height); - (int width, int height) = this.ScaledSize(original.Width, original.Height, this.ThumbnailSize); + (int width, int height) = ScaledSize(original.Width, original.Height, this.ThumbnailSize); using SKBitmap resized = original.Resize(new SKImageInfo(width, height), SKFilterQuality.High); if (resized == null) { @@ -319,7 +321,7 @@ public void SkiaBitmapDecodeToTargetSize(string input) SKImageInfo info = codec.Info; this.LogImageProcessed(info.Width, info.Height); - (int width, int height) = this.ScaledSize(info.Width, info.Height, this.ThumbnailSize); + (int width, int height) = ScaledSize(info.Width, info.Height, this.ThumbnailSize); SKSizeI supportedScale = codec.GetScaledDimensions((float)width / info.Width); using SKBitmap original = SKBitmap.Decode(codec, new SKImageInfo(supportedScale.Width, supportedScale.Height)); diff --git a/tests/ImageSharp.Tests.ProfilingSandbox/LoadResizeSaveParallelMemoryStress.cs b/tests/ImageSharp.Tests.ProfilingSandbox/LoadResizeSaveParallelMemoryStress.cs index 6850756dfe..5e21b7cc1a 100644 --- a/tests/ImageSharp.Tests.ProfilingSandbox/LoadResizeSaveParallelMemoryStress.cs +++ b/tests/ImageSharp.Tests.ProfilingSandbox/LoadResizeSaveParallelMemoryStress.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Globalization; +using System.Runtime.Versioning; using System.Text; using CommandLine; using CommandLine.Text; @@ -13,7 +14,8 @@ namespace SixLabors.ImageSharp.Tests.ProfilingSandbox; // See ImageSharp.Benchmarks/LoadResizeSave/README.md -internal class LoadResizeSaveParallelMemoryStress +[SupportedOSPlatform("windows")] +internal sealed class LoadResizeSaveParallelMemoryStress { private LoadResizeSaveParallelMemoryStress() { @@ -206,14 +208,15 @@ public string GetMarkdown() StringBuilder bld = new(); bld.AppendLine($"| {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(F(nameof(this.TotalSeconds)), this.TotalSeconds); + bld.AppendFormat(CultureInfo.InvariantCulture, F(nameof(this.TotalSeconds)), this.TotalSeconds); bld.Append(" | "); - bld.AppendFormat(F(nameof(this.MegapixelsPerSec)), this.MegapixelsPerSec); + bld.AppendFormat(CultureInfo.InvariantCulture, F(nameof(this.MegapixelsPerSec)), this.MegapixelsPerSec); bld.Append(" | "); - bld.AppendFormat(F(nameof(this.MegapixelsPerSecPerCpu)), this.MegapixelsPerSecPerCpu); + bld.AppendFormat(CultureInfo.InvariantCulture, F(nameof(this.MegapixelsPerSecPerCpu)), this.MegapixelsPerSecPerCpu); bld.AppendLine(" |"); return bld.ToString(); @@ -223,7 +226,7 @@ public string GetMarkdown() } } - private class CommandLineOptions + private sealed class CommandLineOptions { [Option('a', "async-imagesharp", Required = false, Default = false, HelpText = "Async ImageSharp without benchmark switching")] public bool AsyncImageSharp { get; set; } diff --git a/tests/ImageSharp.Tests.ProfilingSandbox/ParallelProcessingStress.Experiment.cs b/tests/ImageSharp.Tests.ProfilingSandbox/ParallelProcessingStress.Experiment.cs new file mode 100644 index 0000000000..ffe09a35b3 --- /dev/null +++ b/tests/ImageSharp.Tests.ProfilingSandbox/ParallelProcessingStress.Experiment.cs @@ -0,0 +1,88 @@ +// 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/ParallelProcessingStress.cs index a9bfda9c36..f5bda66461 100644 --- a/tests/ImageSharp.Tests.ProfilingSandbox/ParallelProcessingStress.cs +++ b/tests/ImageSharp.Tests.ProfilingSandbox/ParallelProcessingStress.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. using System.Diagnostics; +using System.Globalization; using System.Text; using CommandLine; using CommandLine.Text; @@ -10,13 +11,13 @@ namespace SixLabors.ImageSharp.Tests.ProfilingSandbox; -public class ParallelProcessingStress +public sealed partial class ParallelProcessingStress { private CommandLineOptions options; private Configuration configuration; private ulong totalKiloPixels; - public static Stats Run(string[] args) + public static void Run(string[] args) { CommandLineOptions options = null; if (args.Length > 0) @@ -24,13 +25,13 @@ public static Stats Run(string[] args) options = CommandLineOptions.Parse(args); if (options == null) { - return null; + return; } } options ??= new CommandLineOptions(); ParallelProcessingStress stress = new(options.Normalize()); - return stress.Run(); + stress.Run(); } private ParallelProcessingStress(CommandLineOptions options) @@ -66,7 +67,7 @@ private Stats Run() stopwatch.Stop(); double totalMegaPixels = this.totalKiloPixels / 1000.0; - Stats stats = new(stopwatch, totalMegaPixels, systemOptions.MaxDegreeOfParallelism); + Stats stats = new(stopwatch.ElapsedMilliseconds, totalMegaPixels, systemOptions.MaxDegreeOfParallelism); Console.WriteLine(stats.GetMarkdown()); return stats; } @@ -96,7 +97,7 @@ private int Crop() return image.Width * image.Height; } - public record Stats + private sealed record Stats { public double TotalSeconds { get; } @@ -106,10 +107,10 @@ public record Stats public double MegapixelsPerSecPerCpu { get; } - public Stats(Stopwatch sw, double totalMegapixels, int cpuCount) + public Stats(long elapsedMilliseconds, double totalMegapixels, int cpuCount) { this.TotalMegapixels = totalMegapixels; - this.TotalSeconds = sw.ElapsedMilliseconds / 1000.0; + this.TotalSeconds = elapsedMilliseconds / 1000.0; this.MegapixelsPerSec = totalMegapixels / this.TotalSeconds; this.MegapixelsPerSecPerCpu = this.MegapixelsPerSec / cpuCount; } @@ -117,16 +118,19 @@ public Stats(Stopwatch sw, double totalMegapixels, int cpuCount) public string GetMarkdown() { StringBuilder bld = new(); - bld.AppendLine($"| {nameof(this.TotalSeconds)} | {nameof(this.MegapixelsPerSec)} | {nameof(this.MegapixelsPerSecPerCpu)} |"); 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(F(nameof(this.TotalSeconds)), this.TotalSeconds); + bld.AppendFormat(CultureInfo.InvariantCulture, F(nameof(this.TotalSeconds)), this.TotalSeconds); bld.Append(" | "); - bld.AppendFormat(F(nameof(this.MegapixelsPerSec)), this.MegapixelsPerSec); + bld.AppendFormat(CultureInfo.InvariantCulture, F(nameof(this.MegapixelsPerSec)), this.MegapixelsPerSec); bld.Append(" | "); - bld.AppendFormat(F(nameof(this.MegapixelsPerSecPerCpu)), this.MegapixelsPerSecPerCpu); + bld.AppendFormat(CultureInfo.InvariantCulture, F(nameof(this.MegapixelsPerSecPerCpu)), this.MegapixelsPerSecPerCpu); bld.AppendLine(" |"); return bld.ToString(); @@ -136,9 +140,13 @@ public string GetMarkdown() } } - private enum Method { Edges, Crop } + public enum Method + { + Edges, + Crop + } - private class CommandLineOptions + private sealed class CommandLineOptions { [Option('m', "method", Required = false, Default = Method.Edges, HelpText = "The stress test method to run (Edges, Crop)")] public Method Method { get; set; } = Method.Edges; @@ -199,4 +207,4 @@ public static CommandLineOptions Parse(string[] args) return result; } } -} \ No newline at end of file +} diff --git a/tests/ImageSharp.Tests.ProfilingSandbox/Program.cs b/tests/ImageSharp.Tests.ProfilingSandbox/Program.cs index 3245198734..0a1c9b80b1 100644 --- a/tests/ImageSharp.Tests.ProfilingSandbox/Program.cs +++ b/tests/ImageSharp.Tests.ProfilingSandbox/Program.cs @@ -14,7 +14,7 @@ namespace SixLabors.ImageSharp.Tests.ProfilingSandbox; public class Program { - private class ConsoleOutput : ITestOutputHelper + private sealed class ConsoleOutput : ITestOutputHelper { public void WriteLine(string message) => Console.WriteLine(message); @@ -33,7 +33,8 @@ public static void Main(string[] args) try { // LoadResizeSaveParallelMemoryStress.Run(args); - ParallelProcessingStress.Run(args); + ParallelProcessingStress.RunExperiment(args); + // ParallelProcessingStress.Run(args); } catch (Exception ex) { diff --git a/tests/ImageSharp.Tests/TestUtilities/ReferenceCodecs/MagickReferenceDecoder.cs b/tests/ImageSharp.Tests/TestUtilities/ReferenceCodecs/MagickReferenceDecoder.cs index 862d4b64d3..9d46e4dce6 100644 --- a/tests/ImageSharp.Tests/TestUtilities/ReferenceCodecs/MagickReferenceDecoder.cs +++ b/tests/ImageSharp.Tests/TestUtilities/ReferenceCodecs/MagickReferenceDecoder.cs @@ -58,14 +58,14 @@ protected override Image Decode(DecoderOptions options, Stream s MagickReadSettings settings = new() { - FrameCount = (int)options.MaxFrames + FrameCount = options.MaxFrames }; settings.SetDefines(bmpReadDefines); settings.SetDefines(pngReadDefines); using MagickImageCollection magickImageCollection = new(stream, settings); - int imageWidth = magickImageCollection.Max(x => x.Width); - int imageHeight = magickImageCollection.Max(x => x.Height); + int imageWidth = (int)magickImageCollection.Max(x => x.Width); + int imageHeight = (int)magickImageCollection.Max(x => x.Height); List> framesList = []; foreach (IMagickImage magicFrame in magickImageCollection) @@ -74,10 +74,10 @@ protected override Image Decode(DecoderOptions options, Stream s framesList.Add(frame); Buffer2DRegion buffer = frame.PixelBuffer.GetRegion( - imageWidth - magicFrame.Width, - imageHeight - magicFrame.Height, - magicFrame.Width, - magicFrame.Height); + imageWidth - (int)magicFrame.Width, + imageHeight - (int)magicFrame.Height, + (int)magicFrame.Width, + (int)magicFrame.Height); using IUnsafePixelCollection pixels = magicFrame.GetPixelsUnsafe(); if (magicFrame.Depth is 12 or 10 or 8 or 6 or 5 or 4 or 3 or 2 or 1) From 0be2a1350e0bca83f5ddd967785af7bc23f06442 Mon Sep 17 00:00:00 2001 From: antonfirsov Date: Fri, 10 Apr 2026 00:09:12 +0200 Subject: [PATCH 03/12] fix benchmarks --- tests/ImageSharp.Benchmarks/Codecs/Tga/DecodeTga.cs | 2 +- tests/ImageSharp.Benchmarks/Codecs/Webp/DecodeWebp.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/ImageSharp.Benchmarks/Codecs/Tga/DecodeTga.cs b/tests/ImageSharp.Benchmarks/Codecs/Tga/DecodeTga.cs index d6a6cf1fb4..a56b733f01 100644 --- a/tests/ImageSharp.Benchmarks/Codecs/Tga/DecodeTga.cs +++ b/tests/ImageSharp.Benchmarks/Codecs/Tga/DecodeTga.cs @@ -31,7 +31,7 @@ public int TgaImageMagick() { MagickReadSettings settings = new() { Format = MagickFormat.Tga }; using MagickImage image = new(new MemoryStream(this.data), settings); - return image.Width; + return (int)image.Width; } [Benchmark(Description = "ImageSharp Tga")] diff --git a/tests/ImageSharp.Benchmarks/Codecs/Webp/DecodeWebp.cs b/tests/ImageSharp.Benchmarks/Codecs/Webp/DecodeWebp.cs index bba1bc1871..e1c4da8971 100644 --- a/tests/ImageSharp.Benchmarks/Codecs/Webp/DecodeWebp.cs +++ b/tests/ImageSharp.Benchmarks/Codecs/Webp/DecodeWebp.cs @@ -47,7 +47,7 @@ public int WebpLossyMagick() MagickReadSettings settings = new() { Format = MagickFormat.WebP }; using MemoryStream memoryStream = new(this.webpLossyBytes); using MagickImage image = new(memoryStream, settings); - return image.Width; + return (int)image.Width; } [Benchmark(Description = "ImageSharp Lossy Webp")] @@ -55,7 +55,7 @@ public int WebpLossy() { using MemoryStream memoryStream = new(this.webpLossyBytes); using Image image = Image.Load(memoryStream); - return image.Height; + return (int)image.Height; } [Benchmark(Description = "Magick Lossless Webp")] @@ -65,7 +65,7 @@ public int WebpLosslessMagick() { Format = MagickFormat.WebP }; using MemoryStream memoryStream = new(this.webpLossyBytes); using MagickImage image = new(memoryStream, settings); - return image.Width; + return (int)image.Width; } [Benchmark(Description = "ImageSharp Lossless Webp")] From 1ccd05ec3cfbd506bd5b702d80f2f51162e97d58 Mon Sep 17 00:00:00 2001 From: antonfirsov Date: Fri, 10 Apr 2026 00:14:23 +0200 Subject: [PATCH 04/12] 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 @@ 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 static void Run(string[] args) 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 @@ private int Crop() 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 @@ private sealed class CommandLineOptions [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 @@ private sealed class CommandLineOptions 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 CommandLineOptions Normalize() 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); } From 7dce9d949758bf16fab80e9938dfbba732413081 Mon Sep 17 00:00:00 2001 From: antonfirsov Date: Fri, 10 Apr 2026 00:42:57 +0200 Subject: [PATCH 05/12] test additional processors --- .../ProcessorThroughputTest.cs | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputTest.cs b/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputTest.cs index d94aaf1ba5..31a484ee3b 100644 --- a/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputTest.cs +++ b/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputTest.cs @@ -49,7 +49,12 @@ private async Task RunAsync() Func action = this.options.Method switch { Method.Crop => this.Crop, - _ => this.DetectEdges, + Method.Edges => this.DetectEdges, + Method.DrawImage => this.DrawImage, + Method.BinaryThreshold => this.BinaryThreshold, + Method.Histogram => this.Histogram, + Method.OilPaint => this.OilPaint, + _ => throw new NotImplementedException(), }; Console.WriteLine(this.options); @@ -120,6 +125,13 @@ async Task ProcessImage() Console.WriteLine($"MegaPixelsPerSec: {megapixelsPerSec:F2}"); } + private int OilPaint() + { + using Image image = new(this.options.Width, this.options.Height); + image.Mutate(this.configuration, x => x.OilPaint()); + return image.Width * image.Height; + } + private int DetectEdges() { using Image image = new(this.options.Width, this.options.Height); @@ -136,10 +148,36 @@ private int Crop() return image.Width * image.Height; } + private int DrawImage() + { + using Image image = new(this.options.Width, this.options.Height); + using Image foreground = new(this.options.Width, this.options.Height); + image.Mutate(c => c.DrawImage(foreground, 0.5f)); + return image.Width * image.Height; + } + + private int BinaryThreshold() + { + using Image image = new(this.options.Width, this.options.Height); + image.Mutate(c => c.BinaryThreshold(0.5f)); + return image.Width * image.Height; + } + + private int Histogram() + { + using Image image = new(this.options.Width, this.options.Height); + image.Mutate(c => c.HistogramEqualization()); + return image.Width * image.Height; + } + private enum Method { Edges, - Crop + Crop, + DrawImage, + BinaryThreshold, + Histogram, + OilPaint } private sealed class CommandLineOptions From b56a768a31b04d43e1c5b13c109d666823e3d544 Mon Sep 17 00:00:00 2001 From: antonfirsov Date: Fri, 10 Apr 2026 00:56:29 +0200 Subject: [PATCH 06/12] ProcessorThroughputTest -> ProcessorThroughputBenchmark --- ...sorThroughputTest.cs => ProcessorThroughputBenchmark.cs} | 6 +++--- tests/ImageSharp.Tests.ProfilingSandbox/Program.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) rename tests/ImageSharp.Tests.ProfilingSandbox/{ProcessorThroughputTest.cs => ProcessorThroughputBenchmark.cs} (97%) diff --git a/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputTest.cs b/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputBenchmark.cs similarity index 97% rename from tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputTest.cs rename to tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputBenchmark.cs index 31a484ee3b..ddf2e2dbe6 100644 --- a/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputTest.cs +++ b/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputBenchmark.cs @@ -9,14 +9,14 @@ namespace SixLabors.ImageSharp.Tests.ProfilingSandbox; -public sealed class ProcessorThroughputTest +public sealed class ProcessorThroughputBenchmark { private const ulong CountingUnit = 1; private CommandLineOptions options; private Configuration configuration; private ulong totalPixelsInUnit; - private ProcessorThroughputTest(CommandLineOptions options) + private ProcessorThroughputBenchmark(CommandLineOptions options) { this.options = options; this.configuration = Configuration.Default.Clone(); @@ -38,7 +38,7 @@ public static Task RunAsync(string[] args) } options ??= new CommandLineOptions(); - return new ProcessorThroughputTest(options.Normalize()) + return new ProcessorThroughputBenchmark(options.Normalize()) .RunAsync(); } diff --git a/tests/ImageSharp.Tests.ProfilingSandbox/Program.cs b/tests/ImageSharp.Tests.ProfilingSandbox/Program.cs index 8ca57d0c3a..db8892cd75 100644 --- a/tests/ImageSharp.Tests.ProfilingSandbox/Program.cs +++ b/tests/ImageSharp.Tests.ProfilingSandbox/Program.cs @@ -13,7 +13,7 @@ // LoadResizeSaveParallelMemoryStress.Run(args); // ParallelProcessingStress.RunExperiment(args); // ParallelProcessingStress.Run(args); -await ProcessorThroughputTest.RunAsync(args); +await ProcessorThroughputBenchmark.RunAsync(args); // RunToVector4ProfilingTest(); // RunResizeProfilingTest(); From 8de6e10eadd97763f6975e904b6f6d874102b0a2 Mon Sep 17 00:00:00 2001 From: antonfirsov Date: Fri, 10 Apr 2026 02:03:27 +0200 Subject: [PATCH 07/12] add DrawImage to ParallelProcessing benchmark --- .../Processing/ParallelProcessing.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/ImageSharp.Benchmarks/Processing/ParallelProcessing.cs b/tests/ImageSharp.Benchmarks/Processing/ParallelProcessing.cs index 14635136a5..4f2089713d 100644 --- a/tests/ImageSharp.Benchmarks/Processing/ParallelProcessing.cs +++ b/tests/ImageSharp.Benchmarks/Processing/ParallelProcessing.cs @@ -11,6 +11,7 @@ namespace SixLabors.ImageSharp.Benchmarks; public class ParallelProcessing { private Image image; + private Image foreground; private Configuration configuration; public static IEnumerable MaxDegreeOfParallelismValues() @@ -34,6 +35,7 @@ public static IEnumerable MaxDegreeOfParallelismValues() public void Setup() { this.image = new Image(2048, 2048); + this.foreground = new Image(2048, 2048); this.configuration = Configuration.Default.Clone(); this.configuration.MaxDegreeOfParallelism = this.MaxDegreeOfParallelism; } @@ -41,6 +43,9 @@ public void Setup() [Benchmark] public void DetectEdges() => this.image.Mutate(this.configuration, x => x.DetectEdges()); + [Benchmark] + public void DrawImage() => this.image.Mutate(this.configuration, x => x.DrawImage(this.foreground, 0.5f)); + [Benchmark] public void Crop() { @@ -52,5 +57,9 @@ public void Crop() } [GlobalCleanup] - public void Cleanup() => this.image.Dispose(); + public void Cleanup() + { + this.image.Dispose(); + this.foreground.Dispose(); + } } From 8fe7f3205b24464564c81f7f31e91e331f1d79a5 Mon Sep 17 00:00:00 2001 From: antonfirsov Date: Fri, 10 Apr 2026 02:05:17 +0200 Subject: [PATCH 08/12] readonly --- .../ProcessorThroughputBenchmark.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputBenchmark.cs b/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputBenchmark.cs index ddf2e2dbe6..8d3471012f 100644 --- a/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputBenchmark.cs +++ b/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputBenchmark.cs @@ -12,8 +12,8 @@ namespace SixLabors.ImageSharp.Tests.ProfilingSandbox; public sealed class ProcessorThroughputBenchmark { private const ulong CountingUnit = 1; - private CommandLineOptions options; - private Configuration configuration; + private readonly CommandLineOptions options; + private readonly Configuration configuration; private ulong totalPixelsInUnit; private ProcessorThroughputBenchmark(CommandLineOptions options) From d8dbce17ee098b5ad799fc0e06eddf47eb7c1ba5 Mon Sep 17 00:00:00 2001 From: antonfirsov Date: Fri, 10 Apr 2026 02:43:39 +0200 Subject: [PATCH 09/12] Disable BMP tests -- https://github.com/SixLabors/ImageSharp/issues/3112 --- tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs index caa6c507dc..ffefd50dff 100644 --- a/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs @@ -224,8 +224,9 @@ public void BmpDecoder_CanDecode_RunLengthEncoded_8Bit_WithDelta_SystemDrawingRe } } + // RLE8Cut case disabled, see https://github.com/SixLabors/ImageSharp/issues/3112 + // [WithFile(RLE8Cut, PixelTypes.Rgba32)] [Theory] - [WithFile(RLE8Cut, PixelTypes.Rgba32)] [WithFile(RLE8Delta, PixelTypes.Rgba32)] public void BmpDecoder_CanDecode_RunLengthEncoded_8Bit_WithDelta_MagickRefDecoder(TestImageProvider provider) where TPixel : unmanaged, IPixel @@ -236,11 +237,12 @@ public void BmpDecoder_CanDecode_RunLengthEncoded_8Bit_WithDelta_MagickRefDecode image.CompareToOriginal(provider, MagickReferenceDecoder.Png); } + // RLE8Inverted cases disabled, see https://github.com/SixLabors/ImageSharp/issues/3112. + // [WithFile(RLE8Inverted, PixelTypes.Rgba32, false)] + // [WithFile(RLE8Inverted, PixelTypes.Rgba32, true)] [Theory] [WithFile(RLE8, PixelTypes.Rgba32, false)] - [WithFile(RLE8Inverted, PixelTypes.Rgba32, false)] [WithFile(RLE8, PixelTypes.Rgba32, true)] - [WithFile(RLE8Inverted, PixelTypes.Rgba32, true)] public void BmpDecoder_CanDecode_RunLengthEncoded_8Bit(TestImageProvider provider, bool enforceDiscontiguousBuffers) where TPixel : unmanaged, IPixel { From 3f917163d81a9f1eeab9df21dd38ba150e2a7910 Mon Sep 17 00:00:00 2001 From: antonfirsov Date: Fri, 10 Apr 2026 02:58:38 +0200 Subject: [PATCH 10/12] revert ImageMagick update --- tests/Directory.Build.targets | 2 +- .../ImageSharp.Benchmarks/Codecs/Tga/DecodeTga.cs | 2 +- .../Codecs/Webp/DecodeWebp.cs | 6 +++--- .../LoadResizeSave/LoadResizeSaveStressRunner.cs | 6 +++--- .../Formats/Bmp/BmpDecoderTests.cs | 8 +++----- .../ReferenceCodecs/MagickReferenceDecoder.cs | 14 +++++++------- 6 files changed, 18 insertions(+), 20 deletions(-) diff --git a/tests/Directory.Build.targets b/tests/Directory.Build.targets index 6b25509ed8..8c88ff647d 100644 --- a/tests/Directory.Build.targets +++ b/tests/Directory.Build.targets @@ -24,7 +24,7 @@ Do not update to 14+ yet. There's differnce in how the BMP decoder handles rounding in 16 bit images. See https://github.com/ImageMagick/ImageMagick/commit/27a0a9c37f18af9c8d823a3ea076f600843b553c --> - + diff --git a/tests/ImageSharp.Benchmarks/Codecs/Tga/DecodeTga.cs b/tests/ImageSharp.Benchmarks/Codecs/Tga/DecodeTga.cs index a56b733f01..d6a6cf1fb4 100644 --- a/tests/ImageSharp.Benchmarks/Codecs/Tga/DecodeTga.cs +++ b/tests/ImageSharp.Benchmarks/Codecs/Tga/DecodeTga.cs @@ -31,7 +31,7 @@ public int TgaImageMagick() { MagickReadSettings settings = new() { Format = MagickFormat.Tga }; using MagickImage image = new(new MemoryStream(this.data), settings); - return (int)image.Width; + return image.Width; } [Benchmark(Description = "ImageSharp Tga")] diff --git a/tests/ImageSharp.Benchmarks/Codecs/Webp/DecodeWebp.cs b/tests/ImageSharp.Benchmarks/Codecs/Webp/DecodeWebp.cs index e1c4da8971..bba1bc1871 100644 --- a/tests/ImageSharp.Benchmarks/Codecs/Webp/DecodeWebp.cs +++ b/tests/ImageSharp.Benchmarks/Codecs/Webp/DecodeWebp.cs @@ -47,7 +47,7 @@ public int WebpLossyMagick() MagickReadSettings settings = new() { Format = MagickFormat.WebP }; using MemoryStream memoryStream = new(this.webpLossyBytes); using MagickImage image = new(memoryStream, settings); - return (int)image.Width; + return image.Width; } [Benchmark(Description = "ImageSharp Lossy Webp")] @@ -55,7 +55,7 @@ public int WebpLossy() { using MemoryStream memoryStream = new(this.webpLossyBytes); using Image image = Image.Load(memoryStream); - return (int)image.Height; + return image.Height; } [Benchmark(Description = "Magick Lossless Webp")] @@ -65,7 +65,7 @@ public int WebpLosslessMagick() { Format = MagickFormat.WebP }; using MemoryStream memoryStream = new(this.webpLossyBytes); using MagickImage image = new(memoryStream, settings); - return (int)image.Width; + return image.Width; } [Benchmark(Description = "ImageSharp Lossless Webp")] diff --git a/tests/ImageSharp.Benchmarks/LoadResizeSave/LoadResizeSaveStressRunner.cs b/tests/ImageSharp.Benchmarks/LoadResizeSave/LoadResizeSaveStressRunner.cs index 804a60e2cc..ad6c8dbd86 100644 --- a/tests/ImageSharp.Benchmarks/LoadResizeSave/LoadResizeSaveStressRunner.cs +++ b/tests/ImageSharp.Benchmarks/LoadResizeSave/LoadResizeSaveStressRunner.cs @@ -29,7 +29,7 @@ public enum JpegKind } [SupportedOSPlatform("windows")] -public sealed class LoadResizeSaveStressRunner +public class LoadResizeSaveStressRunner { private const int Quality = 75; @@ -250,10 +250,10 @@ public async Task ImageSharpResizeAsync(string input) public void MagickResize(string input) { using MagickImage image = new(input); - this.LogImageProcessed((int)image.Width, (int)image.Height); + this.LogImageProcessed(image.Width, image.Height); // Resize it to fit a 150x150 square - image.Resize((uint)this.ThumbnailSize, (uint)this.ThumbnailSize); + image.Resize(this.ThumbnailSize, this.ThumbnailSize); // Reduce the size of the file image.Strip(); diff --git a/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs index ffefd50dff..caa6c507dc 100644 --- a/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs @@ -224,9 +224,8 @@ public void BmpDecoder_CanDecode_RunLengthEncoded_8Bit_WithDelta_SystemDrawingRe } } - // RLE8Cut case disabled, see https://github.com/SixLabors/ImageSharp/issues/3112 - // [WithFile(RLE8Cut, PixelTypes.Rgba32)] [Theory] + [WithFile(RLE8Cut, PixelTypes.Rgba32)] [WithFile(RLE8Delta, PixelTypes.Rgba32)] public void BmpDecoder_CanDecode_RunLengthEncoded_8Bit_WithDelta_MagickRefDecoder(TestImageProvider provider) where TPixel : unmanaged, IPixel @@ -237,12 +236,11 @@ public void BmpDecoder_CanDecode_RunLengthEncoded_8Bit_WithDelta_MagickRefDecode image.CompareToOriginal(provider, MagickReferenceDecoder.Png); } - // RLE8Inverted cases disabled, see https://github.com/SixLabors/ImageSharp/issues/3112. - // [WithFile(RLE8Inverted, PixelTypes.Rgba32, false)] - // [WithFile(RLE8Inverted, PixelTypes.Rgba32, true)] [Theory] [WithFile(RLE8, PixelTypes.Rgba32, false)] + [WithFile(RLE8Inverted, PixelTypes.Rgba32, false)] [WithFile(RLE8, PixelTypes.Rgba32, true)] + [WithFile(RLE8Inverted, PixelTypes.Rgba32, true)] public void BmpDecoder_CanDecode_RunLengthEncoded_8Bit(TestImageProvider provider, bool enforceDiscontiguousBuffers) where TPixel : unmanaged, IPixel { diff --git a/tests/ImageSharp.Tests/TestUtilities/ReferenceCodecs/MagickReferenceDecoder.cs b/tests/ImageSharp.Tests/TestUtilities/ReferenceCodecs/MagickReferenceDecoder.cs index 9d46e4dce6..862d4b64d3 100644 --- a/tests/ImageSharp.Tests/TestUtilities/ReferenceCodecs/MagickReferenceDecoder.cs +++ b/tests/ImageSharp.Tests/TestUtilities/ReferenceCodecs/MagickReferenceDecoder.cs @@ -58,14 +58,14 @@ protected override Image Decode(DecoderOptions options, Stream s MagickReadSettings settings = new() { - FrameCount = options.MaxFrames + FrameCount = (int)options.MaxFrames }; settings.SetDefines(bmpReadDefines); settings.SetDefines(pngReadDefines); using MagickImageCollection magickImageCollection = new(stream, settings); - int imageWidth = (int)magickImageCollection.Max(x => x.Width); - int imageHeight = (int)magickImageCollection.Max(x => x.Height); + int imageWidth = magickImageCollection.Max(x => x.Width); + int imageHeight = magickImageCollection.Max(x => x.Height); List> framesList = []; foreach (IMagickImage magicFrame in magickImageCollection) @@ -74,10 +74,10 @@ protected override Image Decode(DecoderOptions options, Stream s framesList.Add(frame); Buffer2DRegion buffer = frame.PixelBuffer.GetRegion( - imageWidth - (int)magicFrame.Width, - imageHeight - (int)magicFrame.Height, - (int)magicFrame.Width, - (int)magicFrame.Height); + imageWidth - magicFrame.Width, + imageHeight - magicFrame.Height, + magicFrame.Width, + magicFrame.Height); using IUnsafePixelCollection pixels = magicFrame.GetPixelsUnsafe(); if (magicFrame.Depth is 12 or 10 or 8 or 6 or 5 or 4 or 3 or 2 or 1) From 71131cadc36beb5ac5b25e562d6506b36cceb60f Mon Sep 17 00:00:00 2001 From: antonfirsov Date: Fri, 10 Apr 2026 03:05:20 +0200 Subject: [PATCH 11/12] delete CountingUnit --- .../ProcessorThroughputBenchmark.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputBenchmark.cs b/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputBenchmark.cs index 8d3471012f..790077beff 100644 --- a/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputBenchmark.cs +++ b/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputBenchmark.cs @@ -11,7 +11,6 @@ namespace SixLabors.ImageSharp.Tests.ProfilingSandbox; public sealed class ProcessorThroughputBenchmark { - private const ulong CountingUnit = 1; private readonly CommandLineOptions options; private readonly Configuration configuration; private ulong totalPixelsInUnit; @@ -90,7 +89,7 @@ async Task ProcessImage() } await Task.Yield(); // "emulate IO", i.e., make sure the processing code is async - ulong pixels = (ulong)action() / CountingUnit; + ulong pixels = (ulong)action(); Interlocked.Add(ref this.totalPixelsInUnit, pixels); } catch (Exception ex) @@ -118,7 +117,7 @@ async Task ProcessImage() await drainTcs.Task; stopwatch.Stop(); - double totalMegaPixels = this.totalPixelsInUnit * (double)CountingUnit / 1_000_000.0; + double totalMegaPixels = this.totalPixelsInUnit / 1_000_000.0; double totalSeconds = stopwatch.ElapsedMilliseconds / 1000.0; double megapixelsPerSec = totalMegaPixels / totalSeconds; Console.WriteLine($"TotalSeconds: {totalSeconds:F2}"); From 89e6388e3261118bcc63f86804f3e13e8fbb450d Mon Sep 17 00:00:00 2001 From: antonfirsov Date: Fri, 10 Apr 2026 03:06:22 +0200 Subject: [PATCH 12/12] rename field --- .../ProcessorThroughputBenchmark.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputBenchmark.cs b/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputBenchmark.cs index 790077beff..e9adf58449 100644 --- a/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputBenchmark.cs +++ b/tests/ImageSharp.Tests.ProfilingSandbox/ProcessorThroughputBenchmark.cs @@ -13,7 +13,7 @@ public sealed class ProcessorThroughputBenchmark { private readonly CommandLineOptions options; private readonly Configuration configuration; - private ulong totalPixelsInUnit; + private ulong totalProcessedPixels; private ProcessorThroughputBenchmark(CommandLineOptions options) { @@ -90,7 +90,7 @@ async Task ProcessImage() await Task.Yield(); // "emulate IO", i.e., make sure the processing code is async ulong pixels = (ulong)action(); - Interlocked.Add(ref this.totalPixelsInUnit, pixels); + Interlocked.Add(ref this.totalProcessedPixels, pixels); } catch (Exception ex) { @@ -117,7 +117,7 @@ async Task ProcessImage() await drainTcs.Task; stopwatch.Stop(); - double totalMegaPixels = this.totalPixelsInUnit / 1_000_000.0; + double totalMegaPixels = this.totalProcessedPixels / 1_000_000.0; double totalSeconds = stopwatch.ElapsedMilliseconds / 1000.0; double megapixelsPerSec = totalMegaPixels / totalSeconds; Console.WriteLine($"TotalSeconds: {totalSeconds:F2}");