diff --git a/.gitignore b/.gitignore index bc78471..ecc3099 100644 --- a/.gitignore +++ b/.gitignore @@ -482,3 +482,33 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +test_input_* +test_output_* +ImageConvolution/Agent_Processed_Output/ +ImageConvolution/Processed_Output_Par/ +ImageConvolution/Processed_Output_Seq/ +ImageConvolution/Processed_Output_*/ +repomix-output.* +repomix-output.xml + +# Local benchmark output and generated image fixtures +MeasurementResults/ +Test4K/ +Processed_*/ +Agent_Processed_Output/ +CPU_Sequential_Output/ +GPU_Batch_Output/ +Unified_Output/ +ImageSharp_Output/ +ImageSharp_Native_Output/ +ImageSharp_Direct_L8_Output/ + +# Local profiler traces +*.diagsession +*.etl +*.nettrace + +# One-off local experiment launchers +run-*.ps1 +resume-*.ps1 diff --git a/ImageConvolution.Tests/AdditionalWorkflowTests.cs b/ImageConvolution.Tests/AdditionalWorkflowTests.cs new file mode 100644 index 0000000..6a16d74 --- /dev/null +++ b/ImageConvolution.Tests/AdditionalWorkflowTests.cs @@ -0,0 +1,198 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; + +using ImageConvolution; + +using Xunit; + +namespace ImageConvolution.Tests; + +public sealed class AdditionalWorkflowTests +{ + [Fact] + public void SequentialFolderProcessingWritesTheExpectedPixels() + { + using var workspace = new ImageWorkspace(); + string output = Path.Combine(workspace.Root, "sequential"); + + BatchProcessor.ProcessImagesSequential(workspace.InputDirectory, output); + + byte[,] actual = ImageIO.LoadAsGrayscaleByte(Path.Combine(output, "test.png")); + byte[,] expected = ConvolutionProcessor.Convolve(workspace.Pixels, Kernels.BlurBox, EdgeStrategy.Extend); + AssertPixelsEqual(expected, actual); + } + + [Fact] + public void DirectImageSharpFolderProcessingWritesAnImageAndProfilesItsStages() + { + using var workspace = new ImageWorkspace(); + string output = Path.Combine(workspace.Root, "direct"); + using var session = new PerformanceProfiler.Session(); + + LibraryProcessor.ProcessImagesWithImageSharpDirect(workspace.InputDirectory, output); + + var profile = session.Capture(); + Assert.True(File.Exists(Path.Combine(output, "test.png"))); + byte[,] result = ImageIO.LoadAsGrayscaleByte(Path.Combine(output, "test.png")); + Assert.Equal(workspace.Pixels.GetLength(0), result.GetLength(0)); + Assert.Equal(workspace.Pixels.GetLength(1), result.GetLength(1)); + Assert.Equal(1L, Assert.Single(profile.Stages, stage => stage.Name == "imagesharp.direct.blur").Count); + } + + [Fact] + public void MissingInputFolderDoesNotCreateAnOutputFolder() + { + using var workspace = new ImageWorkspace(); + string output = Path.Combine(workspace.Root, "missing-output"); + BatchProcessor.ProcessImagesSequential(Path.Combine(workspace.Root, "missing"), output); + LibraryProcessor.ProcessImagesWithImageSharpDirect(Path.Combine(workspace.Root, "missing"), output); + Assert.False(Directory.Exists(output)); + } + + [Fact] + public void LocalityRunRecordsEveryTraversal() + { + using var workspace = new ImageWorkspace(); + string results = ExperimentRunner.ResultsDirectory; + string[] names = ["Locality-Rows", "Locality-Columns", "Locality-BlockedColumns"]; + try + { + LocalityExperiment.Run(workspace.InputPath); + foreach (string name in names) + { + string folder = Path.Combine(results, name); + using var summary = JsonDocument.Parse(File.ReadAllText(Path.Combine(folder, "summary.json"))); + Assert.Equal(25, summary.RootElement.GetProperty("measuredRuns").GetInt32()); + } + } + finally + { + foreach (string name in names) + { + string folder = Path.Combine(results, name); + if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true); + } + } + } + + [Fact] + public void ProfilingExperimentRecordsStageSummary() + { + int previousMode = PerformanceProfiler.Mode; + string name = "profile-smoke-" + Guid.NewGuid().ToString("N"); + string folder = Path.Combine(ExperimentRunner.ResultsDirectory, name); + try + { + PerformanceProfiler.Mode = 1; + ExperimentRunner.Run(name, () => PerformanceProfiler.Measure("test.stage", () => { }), warmupRuns: 0); + using var summary = JsonDocument.Parse(File.ReadAllText(Path.Combine(folder, "summary.json"))); + JsonElement stage = Assert.Single(summary.RootElement.GetProperty("stageMedians").EnumerateArray()); + Assert.Equal("test.stage", stage.GetProperty("name").GetString()); + Assert.Equal(25, stage.GetProperty("runsPresent").GetInt32()); + Assert.Contains("Медиана процессорного времени", File.ReadAllText(Path.Combine(folder, "summary.txt"))); + } + finally + { + PerformanceProfiler.Mode = previousMode; + if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true); + } + } + + [Fact] + public void DescribeArrayCapturesElementSizeAndAddressSteps() + { + using var session = new PerformanceProfiler.Session(); + PerformanceProfiler.DescribeArray(new byte[2, 3]); + var metadata = session.Capture().Metadata; + Assert.Contains("layout.Byte.3x2", metadata.Keys); + } + + [Fact] + public void InvalidConvolutionInputsAreRejected() + { + byte[,] pixels = new byte[2, 2]; + Assert.Throws(() => ConvolutionProcessor.Convolve(null!, Kernels.BlurBox)); + Assert.Throws(() => ConvolutionProcessor.Convolve(pixels, null!)); + Assert.Throws(() => ConvolutionProcessor.Convolve(new byte[0, 2], Kernels.BlurBox)); + Assert.Throws(() => ConvolutionProcessor.Convolve(pixels, new float[,] { { float.NaN } })); + Assert.Throws(() => + ConvolutionProcessor.Convolve(pixels, Kernels.BlurBox, (EdgeStrategy)123)); + } + + [Fact] + public void ParallelColumnFolderProcessingMatchesSequentialPixels() + { + using var workspace = new ImageWorkspace(); + string output = Path.Combine(workspace.Root, "columns"); + BatchProcessor.ProcessImagesNaiveParallel(workspace.InputDirectory, output, ParallelStrategy.ParallelByColumns, 2); + byte[,] actual = ImageIO.LoadAsGrayscaleByte(Path.Combine(output, "test.png")); + byte[,] expected = ConvolutionProcessor.Convolve(workspace.Pixels, Kernels.BlurBox, EdgeStrategy.Extend); + AssertPixelsEqual(expected, actual); + } + + [Fact] + public void FolderProcessorsRejectUsingTheInputAsOutput() + { + using var workspace = new ImageWorkspace(); + Assert.Throws(() => + BatchProcessor.ProcessImagesSequential(workspace.InputDirectory, workspace.InputDirectory)); + Assert.Throws(() => + LibraryProcessor.ProcessImagesWithImageSharpDirect(workspace.InputDirectory, workspace.InputDirectory)); + } + + [Fact] + public void InvalidPipelineConfigurationFailsBeforeStartingWorkers() + { + Assert.Throws(() => new UnifiedProcessor(new UnifiedProcessorConfig + { + CpuWorkers = 1, + GpuWorkers = 0, + ReaderThreads = 0 + })); + Assert.Throws(() => new UnifiedProcessor(new UnifiedProcessorConfig + { + CpuWorkers = 1, + GpuWorkers = 0, + CpuStrategy = (ParallelStrategy)123 + })); + } + + [Fact] + public void InvalidLocalityOptionsAreRejected() + { + byte[,] pixels = new byte[2, 2]; + Assert.Throws(() => + LocalityExperiment.Convolve(pixels, Kernels.BlurBox, EdgeStrategy.Extend, (SequentialTraversal)123)); + Assert.Throws(() => + LocalityExperiment.Convolve(pixels, Kernels.BlurBox, EdgeStrategy.Extend, SequentialTraversal.Rows, blockSize: 0)); + Assert.Throws(() => LocalityExperiment.HardwareWorkload(["--locality-profile"])); + } + + private static void AssertPixelsEqual(byte[,] expected, byte[,] actual) + { + Assert.Equal(expected.GetLength(0), actual.GetLength(0)); + Assert.Equal(expected.GetLength(1), actual.GetLength(1)); + for (int y = 0; y < expected.GetLength(0); y++) + for (int x = 0; x < expected.GetLength(1); x++) Assert.Equal(expected[y, x], actual[y, x]); + } + + private sealed class ImageWorkspace : IDisposable + { + public string Root { get; } = Path.Combine(Path.GetTempPath(), "imageconv-workflow-" + Guid.NewGuid().ToString("N")); + public string InputDirectory => Path.Combine(Root, "input"); + public string InputPath => Path.Combine(InputDirectory, "test.png"); + public byte[,] Pixels { get; } = new byte[7, 9]; + + public ImageWorkspace() + { + Directory.CreateDirectory(InputDirectory); + for (int y = 0; y < Pixels.GetLength(0); y++) + for (int x = 0; x < Pixels.GetLength(1); x++) Pixels[y, x] = (byte)((x * 17 + y * 23) % 256); + ImageIO.SaveImageByte(Pixels, InputPath); + } + + public void Dispose() => Directory.Delete(Root, recursive: true); + } +} \ No newline at end of file diff --git a/ImageConvolution.Tests/ConvolutionTests.cs b/ImageConvolution.Tests/ConvolutionTests.cs new file mode 100644 index 0000000..0127ee6 --- /dev/null +++ b/ImageConvolution.Tests/ConvolutionTests.cs @@ -0,0 +1,378 @@ +using System; +using System.IO; + +using ImageConvolution; + +using Xunit; + +namespace ImageConvolution.Tests; + +public class ConvolutionTests +{ + [Theory] + [InlineData(1, 1, 32)] + [InlineData(2, 3, 2)] + [InlineData(35, 33, 32)] + [InlineData(17, 9, 1)] + public void LocalityTraversalsMatchForAllPixels(int width, int height, int blockSize) + { + var image = new byte[height, width]; + var random = new Random(42); + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) image[y, x] = (byte)random.Next(256); + float[,] kernel = { { 1, -2, 3 }, { 4, 0.5f, -6 }, { 7, 8, 9 } }; + foreach (var edge in new[] { EdgeStrategy.Extend, EdgeStrategy.ZeroPadding }) + { + var expected = ConvolutionProcessor.Convolve(image, kernel, edge); + foreach (var traversal in Enum.GetValues()) + { + var actual = LocalityExperiment.Convolve(image, kernel, edge, traversal, blockSize); + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) Assert.Equal(expected[y, x], actual[y, x]); + } + var parallel = ParallelConvolutionProcessor.ConvolveParallelByColumns(image, kernel, edge); + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) Assert.Equal(expected[y, x], parallel[y, x]); + } + } + + [Theory] + [InlineData(1, 1)] + [InlineData(2, 3)] + [InlineData(17, 9)] + public void NativeImageSharpMatchesWrapperAndCpu(int width, int height) + { + string root = Path.Combine(Path.GetTempPath(), "ImageSharpNative-" + Guid.NewGuid().ToString("N")); + string input = Path.Combine(root, "input"); + string native = Path.Combine(root, "native"); + string wrapper = Path.Combine(root, "wrapper"); + Directory.CreateDirectory(input); + try + { + string path = Path.Combine(input, "test.png"); + using (var image = new SixLabors.ImageSharp.Image(width, height)) + { + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) + image[x, y] = new((byte)((x * 73 + y * 19) % 256), (byte)((x * 11 + y * 97) % 256), + (byte)((x * 31 + y * 47) % 256), (byte)((x * 17 + y * 23) % 256)); + SixLabors.ImageSharp.ImageExtensions.Save(image, path); + } + using var session = new PerformanceProfiler.Session(); + LibraryProcessor.ProcessImagesWithImageSharpNative(input, native); + var profile = session.Capture(); + Assert.Equal(1L, Assert.Single(profile.Stages, s => s.Name == "imagesharp.native.blur").Count); + LibraryProcessor.ProcessImagesWithImageSharp(input, wrapper); + var actual = ImageIO.LoadAsGrayscaleByte(Path.Combine(native, "test.png")); + var previous = ImageIO.LoadAsGrayscaleByte(Path.Combine(wrapper, "test.png")); + var expected = UnifiedProcessor.ConvolveCpu(ImageIO.LoadAsGrayscaleByte(path), Kernels.BlurBoxFloat, EdgeStrategy.Extend); + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) + { + Assert.InRange(Math.Abs(actual[y, x] - previous[y, x]), 0, 1); + Assert.InRange(Math.Abs(actual[y, x] - ImageIO.ToByte(expected[y, x])), 0, 1); + } + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, true); + } + } + + [Fact] + public void Test_ImageIO_SaveAndLoad() + { + string testPath = "test_image.png"; + byte[,] originalData = new byte[2, 2] { { 0, 255 }, { 128, 64 } }; + + try + { + ImageIO.SaveImageByte(originalData, testPath); + Assert.True(File.Exists(testPath)); + + byte[,] loadedData = ImageIO.LoadAsGrayscaleByte(testPath); + + Assert.Equal(2, loadedData.GetLength(0)); + Assert.Equal(2, loadedData.GetLength(1)); + } + finally + { + if (File.Exists(testPath)) File.Delete(testPath); + } + } + + [Fact] + public void Test_Convolve_ExtendStrategy() + { + byte[,] image = { { 10, 10, 10 }, { 10, 10, 10 }, { 10, 10, 10 } }; + byte[,] result = ConvolutionProcessor.Convolve(image, Kernels.BlurBox, EdgeStrategy.Extend); + Assert.Equal(10, result[1, 1]); + } + + [Fact] + public void Test_Convolve_ZeroPaddingStrategy() + { + byte[,] image = { { 10, 10, 10 }, { 10, 10, 10 }, { 10, 10, 10 } }; + byte[,] result = ConvolutionProcessor.Convolve(image, Kernels.BlurBox, EdgeStrategy.ZeroPadding); + Assert.True(result[0, 0] < 10); + } + + [Fact] + public void Test_ConvolveParallel() + { + byte[,] image = { { 100, 100, 100 }, { 100, 100, 100 }, { 100, 100, 100 } }; + byte[,] result = ParallelConvolutionProcessor.ConvolveParallel(image, Kernels.BlurBox, EdgeStrategy.Extend); + Assert.Equal(100, result[1, 1]); + } + + [Fact] + public void Test_KernelsExist() + { + Assert.NotNull(Kernels.BlurBox); + Assert.NotNull(Kernels.Sharpen); + Assert.NotNull(Kernels.Laplacian); + } + + [Fact] + public void Test_ConvolveParallel_ZeroPadding() + { + byte[,] image = { + { 10, 10, 10 }, + { 10, 10, 10 }, + { 10, 10, 10 } + }; + + byte[,] result = ParallelConvolutionProcessor.ConvolveParallel( + image, + Kernels.BlurBox, + EdgeStrategy.ZeroPadding); + + Assert.True(result[0, 0] < 10); + } + + private void CreateTestImage(string directory, string filename) + { + Directory.CreateDirectory(directory); + byte[,] dummyPixelData = new byte[2, 2] { { 128, 128 }, { 128, 128 } }; + string fullPath = Path.Combine(directory, filename); + ImageIO.SaveImageByte(dummyPixelData, fullPath); + } + + [Fact] + public void Test_BatchProcessor_Naive_Sequential() + { + string inputDir = "test_input_naive_seq"; + string outputDir = "test_output_naive_seq"; + + try + { + CreateTestImage(inputDir, "test1.jpg"); + + BatchProcessor.ProcessImagesNaiveParallel(inputDir, outputDir, ParallelStrategy.Sequential); + + Assert.True(Directory.Exists(outputDir)); + Assert.Single(Directory.GetFiles(outputDir)); + } + finally + { + if (Directory.Exists(inputDir)) Directory.Delete(inputDir, true); + if (Directory.Exists(outputDir)) Directory.Delete(outputDir, true); + } + } + + [Fact] + public void Test_BatchProcessor_Naive_Parallel() + { + string inputDir = "test_input_naive_par"; + string outputDir = "test_output_naive_par"; + + try + { + CreateTestImage(inputDir, "test1.jpg"); + + BatchProcessor.ProcessImagesNaiveParallel(inputDir, outputDir, ParallelStrategy.ParallelByRows); + + Assert.True(Directory.Exists(outputDir)); + Assert.Single(Directory.GetFiles(outputDir)); + } + finally + { + if (Directory.Exists(inputDir)) Directory.Delete(inputDir, true); + if (Directory.Exists(outputDir)) Directory.Delete(outputDir, true); + } + } + + [Fact] + public void Test_AgentProcessor_Success() + { + string inputDir = "test_input_agents"; + string outputDir = "test_output_agents"; + + try + { + CreateTestImage(inputDir, "test1.jpg"); + CreateTestImage(inputDir, "test2.jpg"); + + AgentProcessor.ProcessImagesWithAgents(inputDir, outputDir, workerCount: 2); + + Assert.True(Directory.Exists(outputDir)); + Assert.Equal(2, Directory.GetFiles(outputDir).Length); + } + finally + { + if (Directory.Exists(inputDir)) Directory.Delete(inputDir, true); + if (Directory.Exists(outputDir)) Directory.Delete(outputDir, true); + } + } + + [Fact] + public void Test_BatchProcessor_NonExistentFolder() + { + string nonExistentDir = "non_existent_folder_abc_123"; + string outputDir = "output_abc"; + + BatchProcessor.ProcessImagesNaiveParallel(nonExistentDir, outputDir, ParallelStrategy.Sequential); + + Assert.False(Directory.Exists(outputDir)); + } + + [Fact] + public void Test_AgentProcessor_NonExistentFolder() + { + string nonExistentDir = "non_existent_folder_xyz_999"; + string outputDir = "output_xyz"; + + AgentProcessor.ProcessImagesWithAgents(nonExistentDir, outputDir, 2); + + Assert.False(Directory.Exists(outputDir)); + } + + [Fact] + public void Test_CustomKernelSize_5x5() + { + byte[,] image = new byte[10, 10]; + for (int i = 0; i < 10; i++) + for (int j = 0; j < 10; j++) image[i, j] = 100; + + float[,] kernel5x5 = new float[5, 5]; + for (int i = 0; i < 5; i++) + for (int j = 0; j < 5; j++) kernel5x5[i, j] = 1f / 25f; + + byte[,] result = ConvolutionProcessor.Convolve(image, kernel5x5, EdgeStrategy.Extend); + Assert.Equal(100, result[5, 5]); + } + + [Fact] + public void Test_SharpenAndLaplacianKernels() + { + byte[,] image = { { 50, 50, 50 }, { 50, 50, 50 }, { 50, 50, 50 } }; + + var resLapl = ConvolutionProcessor.Convolve(image, Kernels.Laplacian); + var resSharp = ConvolutionProcessor.Convolve(image, Kernels.Sharpen); + + Assert.Equal(0, resLapl[1, 1]); + Assert.Equal(50, resSharp[1, 1]); + } + + private void CreateLargeTestImage(string directory, string filename) + { + Directory.CreateDirectory(directory); + byte[,] dummyPixelData = new byte[20, 20]; + for (int y = 0; y < 20; y++) + for (int x = 0; x < 20; x++) + dummyPixelData[y, x] = 128; + + string fullPath = Path.Combine(directory, filename); + ImageIO.SaveImageByte(dummyPixelData, fullPath); + } + + [Fact] + public void Test_LibraryProcessor_Parallel_Success() + { + string inputDir = "test_input_lib_par"; + string outputDir = "test_output_lib_par"; + try + { + CreateLargeTestImage(inputDir, "lib_test.jpg"); + + LibraryProcessor.ProcessImagesWithImageSharp(inputDir, outputDir); + + Assert.True(Directory.Exists(outputDir)); + Assert.Single(Directory.GetFiles(outputDir)); + } + finally + { + if (Directory.Exists(inputDir)) Directory.Delete(inputDir, true); + if (Directory.Exists(outputDir)) Directory.Delete(outputDir, true); + } + } + [Fact] + public void Test_BatchProcessor_EmptyFolder() + { + string root = Path.Combine(Path.GetTempPath(), "imageconv-empty-batch-" + Guid.NewGuid().ToString("N")); + string input = Path.Combine(root, "input"); + string output = Path.Combine(root, "output"); + try + { + Directory.CreateDirectory(input); + BatchProcessor.ProcessImagesNaiveParallel(input, output, ParallelStrategy.Sequential); + Assert.Empty(Directory.GetFiles(output)); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + + [Fact] + public void Test_AgentProcessor_EmptyFolder() + { + string root = Path.Combine(Path.GetTempPath(), "imageconv-empty-agent-" + Guid.NewGuid().ToString("N")); + string input = Path.Combine(root, "input"); + string output = Path.Combine(root, "output"); + try + { + Directory.CreateDirectory(input); + AgentProcessor.ProcessImagesWithAgents(input, output, 2); + Assert.Empty(Directory.GetFiles(output)); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + + [Fact] + public void Test_LibraryProcessor_EmptyFolder() + { + string root = Path.Combine(Path.GetTempPath(), "imageconv-empty-library-" + Guid.NewGuid().ToString("N")); + string input = Path.Combine(root, "input"); + string output = Path.Combine(root, "output"); + try + { + Directory.CreateDirectory(input); + LibraryProcessor.ProcessImagesWithImageSharp(input, output); + Assert.Empty(Directory.GetFiles(output)); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + + [Fact] + public void Test_LibraryProcessor_NonExistentFolder() + { + string root = Path.Combine(Path.GetTempPath(), "imageconv-missing-library-" + Guid.NewGuid().ToString("N")); + LibraryProcessor.ProcessImagesWithImageSharp(Path.Combine(root, "input"), Path.Combine(root, "output")); + Assert.False(Directory.Exists(root)); + } + + [Fact] + public void Test_BatchProcessor_CorruptImage() + { + string root = Path.Combine(Path.GetTempPath(), "imageconv-corrupt-batch-" + Guid.NewGuid().ToString("N")); + string input = Path.Combine(root, "input"); + string output = Path.Combine(root, "output"); + try + { + Directory.CreateDirectory(input); + File.WriteAllText(Path.Combine(input, "corrupt.jpg"), "bad data"); + Assert.Throws( + () => BatchProcessor.ProcessImagesNaiveParallel(input, output, ParallelStrategy.Sequential)); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + +} \ No newline at end of file diff --git a/ImageConvolution.Tests/CudaTestAttributes.cs b/ImageConvolution.Tests/CudaTestAttributes.cs new file mode 100644 index 0000000..7a0f224 --- /dev/null +++ b/ImageConvolution.Tests/CudaTestAttributes.cs @@ -0,0 +1,35 @@ +using System; + +using ImageConvolution; + +using Xunit; + +namespace ImageConvolution.Tests; + +internal static class CudaAvailability +{ + private static readonly Lazy available = new(() => + { + try { return GpuConvolutionProcessor.GetDeviceCount() > 0; } + catch (Exception) { return false; } + }); + + public static bool IsAvailable => Environment.GetEnvironmentVariable("IMAGECONV_SKIP_CUDA_TESTS") != "1" + && available.Value; +} + +public sealed class CudaFactAttribute : FactAttribute +{ + public CudaFactAttribute() + { + if (!CudaAvailability.IsAvailable) Skip = "Requires a CUDA GPU."; + } +} + +public sealed class CudaTheoryAttribute : TheoryAttribute +{ + public CudaTheoryAttribute() + { + if (!CudaAvailability.IsAvailable) Skip = "Requires a CUDA GPU."; + } +} \ No newline at end of file diff --git a/ImageConvolution.Tests/GpuConvolutionTests.cs b/ImageConvolution.Tests/GpuConvolutionTests.cs new file mode 100644 index 0000000..358181e --- /dev/null +++ b/ImageConvolution.Tests/GpuConvolutionTests.cs @@ -0,0 +1,650 @@ +using System; +using System.IO; + +using Xunit; + +namespace ImageConvolution.Tests +{ + public class GpuConvolutionTests : IDisposable + { + private const string TestDirInput = "gpu_test_input"; + private const string TestDirOutput = "gpu_test_output"; + + public GpuConvolutionTests() + { + Cleanup(); + } + + private void Cleanup() + { + if (Directory.Exists(TestDirInput)) Directory.Delete(TestDirInput, true); + if (Directory.Exists(TestDirOutput)) Directory.Delete(TestDirOutput, true); + } + + public void Dispose() + { + Cleanup(); + GC.SuppressFinalize(this); + } + + [CudaFact] + public void GpuProcessor_CanBeConstructedAndDisposed() + { + using var gpuProcessor = new GpuConvolutionProcessor(); + Assert.NotNull(gpuProcessor); + } + + [CudaFact] + public void ConvolveGpu_ExtendStrategy_ProducesCorrectCenterPixel() + { + using var gpuProcessor = new GpuConvolutionProcessor(); + var image = new byte[5, 5]; + for (int i = 0; i < 5; i++) + for (int j = 0; j < 5; j++) + image[i, j] = 100; + + var result = gpuProcessor.ConvolveGpu(image, Kernels.BlurBoxFloat, EdgeStrategy.Extend); + + Assert.Equal(100, result[2, 2]); + } + + [CudaFact] + public void ConvolveGpu_ZeroPaddingStrategy_AffectsCornerPixel() + { + using var gpuProcessor = new GpuConvolutionProcessor(); + var image = new byte[5, 5]; + for (int i = 0; i < 5; i++) + for (int j = 0; j < 5; j++) + image[i, j] = 100; + + var result = gpuProcessor.ConvolveGpu(image, Kernels.BlurBoxFloat, EdgeStrategy.ZeroPadding); + + Assert.True(result[0, 0] < 50); + } + + [CudaTheory] + [InlineData(EdgeStrategy.Extend)] + [InlineData(EdgeStrategy.ZeroPadding)] + public void ConvolveGpu_BytePixelsMatchCpu(EdgeStrategy edge) + { + var image = new byte[7, 9]; + for (int y = 0; y < image.GetLength(0); y++) + for (int x = 0; x < image.GetLength(1); x++) + image[y, x] = (byte)((y * 47 + x * 29) % 256); + + var expected = ConvolutionProcessor.Convolve(image, Kernels.BlurBox, edge); + using var gpu = new GpuConvolutionProcessor(); + var actual = gpu.ConvolveGpu(image, Kernels.BlurBox, edge); + + for (int y = 0; y < image.GetLength(0); y++) + for (int x = 0; x < image.GetLength(1); x++) + Assert.InRange(Math.Abs((int)actual[y, x] - expected[y, x]), 0, 1); + } + + [CudaFact] + public void ProcessDirectory_ProcessesFilesCorrectly() + { + CreateTestImageByte(TestDirInput, "test1.jpg", 10, 10); + CreateTestImageByte(TestDirInput, "test2.jpg", 10, 10); + + GpuConvolutionProcessor.ProcessDirectory(TestDirInput, TestDirOutput, Kernels.BlurBoxFloat, EdgeStrategy.Extend); + + Assert.True(Directory.Exists(TestDirOutput)); + Assert.Equal(2, Directory.GetFiles(TestDirOutput).Length); + Assert.True(File.Exists(Path.Combine(TestDirOutput, "test1.jpg"))); + Assert.True(File.Exists(Path.Combine(TestDirOutput, "test2.jpg"))); + } + + [CudaFact] + public void ProcessDirectory_ProcessesDifferentSizeFiles() + { + CreateTestImageByte(TestDirInput, "test_10x10.jpg", 10, 10); + CreateTestImageByte(TestDirInput, "test_20x20.jpg", 20, 20); + + GpuConvolutionProcessor.ProcessDirectory(TestDirInput, TestDirOutput, Kernels.BlurBoxFloat, EdgeStrategy.Extend); + + Assert.True(Directory.Exists(TestDirOutput)); + Assert.Equal(2, Directory.GetFiles(TestDirOutput).Length); + Assert.True(File.Exists(Path.Combine(TestDirOutput, "test_10x10.jpg"))); + Assert.True(File.Exists(Path.Combine(TestDirOutput, "test_20x20.jpg"))); + } + + [Fact] + public void ProcessDirectory_HandlesNonExistentInputDirectory() + { + GpuConvolutionProcessor.ProcessDirectory("non_existent_dir_12345", TestDirOutput, Kernels.BlurBoxFloat, EdgeStrategy.Extend); + + Assert.False(Directory.Exists(TestDirOutput)); + } + + [Fact] + public void ProcessDirectory_HandlesEmptyInputDirectory() + { + Directory.CreateDirectory(TestDirInput); + + GpuConvolutionProcessor.ProcessDirectory(TestDirInput, TestDirOutput, Kernels.BlurBoxFloat, EdgeStrategy.Extend); + + Assert.True(Directory.Exists(TestDirOutput)); + Assert.Empty(Directory.GetFiles(TestDirOutput)); + } + + private void CreateTestImageByte(string directory, string filename, int width, int height) + { + Directory.CreateDirectory(directory); + var dummyPixelData = new byte[height, width]; + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) + dummyPixelData[y, x] = 128; + + string fullPath = Path.Combine(directory, filename); + ImageIO.SaveImageByte(dummyPixelData, fullPath); + } + [Fact] + public void UnifiedProcessor_CanBeConstructedAndDisposed() + { + var config = new UnifiedProcessorConfig + { + CpuWorkers = 1, + GpuWorkers = 1, + ReaderThreads = 1, + WriterThreads = 1 + }; + + using var processor = new UnifiedProcessor(config); + Assert.NotNull(processor); + } + + [Fact] + public void UnifiedProcessor_ProcessesDirectoryWithCpuOnly() + { + CreateTestImageByte(TestDirInput, "test1.jpg", 10, 10); + CreateTestImageByte(TestDirInput, "test2.jpg", 10, 10); + + var config = new UnifiedProcessorConfig + { + CpuWorkers = 2, + GpuWorkers = 0, + ReaderThreads = 1, + WriterThreads = 1 + }; + + using var processor = new UnifiedProcessor(config); + processor.ProcessDirectory(TestDirInput, TestDirOutput); + + Assert.True(Directory.Exists(TestDirOutput)); + Assert.Equal(2, Directory.GetFiles(TestDirOutput).Length); + } + + [CudaFact] + public void UnifiedProcessor_ProcessesDirectoryWithGpuOnly() + { + CreateTestImageByte(TestDirInput, "test1.jpg", 10, 10); + CreateTestImageByte(TestDirInput, "test2.jpg", 10, 10); + + var config = new UnifiedProcessorConfig + { + CpuWorkers = 0, + GpuWorkers = 1, + ReaderThreads = 1, + WriterThreads = 1 + }; + + using var processor = new UnifiedProcessor(config); + processor.ProcessDirectory(TestDirInput, TestDirOutput); + + Assert.True(Directory.Exists(TestDirOutput)); + Assert.Equal(2, Directory.GetFiles(TestDirOutput).Length); + } + + [CudaFact] + public void UnifiedProcessor_ProcessesDirectoryWithMixedWorkers() + { + CreateTestImageByte(TestDirInput, "test1.jpg", 10, 10); + CreateTestImageByte(TestDirInput, "test2.jpg", 10, 10); + CreateTestImageByte(TestDirInput, "test3.jpg", 10, 10); + + var config = new UnifiedProcessorConfig + { + CpuWorkers = 1, + GpuWorkers = 1, + ReaderThreads = 1, + WriterThreads = 1 + }; + + using var processor = new UnifiedProcessor(config); + processor.ProcessDirectory(TestDirInput, TestDirOutput); + + Assert.True(Directory.Exists(TestDirOutput)); + Assert.Equal(3, Directory.GetFiles(TestDirOutput).Length); + } + + [Fact] + public void UnifiedProcessor_HandlesNonExistentDirectory() + { + var config = new UnifiedProcessorConfig + { + CpuWorkers = 1, + GpuWorkers = 1 + }; + + using var processor = new UnifiedProcessor(config); + processor.ProcessDirectory("non_existent_12345", TestDirOutput); + + Assert.False(Directory.Exists(TestDirOutput)); + } + + [Fact] + public void UnifiedProcessor_HandlesEmptyDirectory() + { + Directory.CreateDirectory(TestDirInput); + + var config = new UnifiedProcessorConfig + { + CpuWorkers = 1, + GpuWorkers = 1 + }; + + using var processor = new UnifiedProcessor(config); + processor.ProcessDirectory(TestDirInput, TestDirOutput); + + Assert.True(Directory.Exists(TestDirOutput)); + Assert.Empty(Directory.GetFiles(TestDirOutput)); + } + + [Fact] + public void UnifiedProcessor_UsesConfiguredKernel() + { + CreateTestImageByte(TestDirInput, "test.jpg", 10, 10); + + var customKernel = new float[3, 3] + { + { 0, 0, 0 }, + { 0, 1, 0 }, + { 0, 0, 0 } + }; + + var config = new UnifiedProcessorConfig + { + CpuWorkers = 1, + GpuWorkers = 0, + Kernel = customKernel + }; + + using var processor = new UnifiedProcessor(config); + processor.ProcessDirectory(TestDirInput, TestDirOutput); + + Assert.Single(Directory.GetFiles(TestDirOutput)); + } + + [Fact] + public void UnifiedProcessor_UsesConfiguredEdgeStrategy() + { + CreateTestImageByte(TestDirInput, "test.jpg", 10, 10); + + var config = new UnifiedProcessorConfig + { + CpuWorkers = 1, + GpuWorkers = 0, + Strategy = EdgeStrategy.ZeroPadding + }; + + using var processor = new UnifiedProcessor(config); + processor.ProcessDirectory(TestDirInput, TestDirOutput); + + Assert.Single(Directory.GetFiles(TestDirOutput)); + } + + [CudaFact] + public void UnifiedProcessor_MultipleReadersAndWriters() + { + for (int i = 0; i < 5; i++) + { + CreateTestImageByte(TestDirInput, $"test{i}.jpg", 10, 10); + } + + var config = new UnifiedProcessorConfig + { + CpuWorkers = 2, + GpuWorkers = 1, + ReaderThreads = 3, + WriterThreads = 3 + }; + + using var processor = new UnifiedProcessor(config); + processor.ProcessDirectory(TestDirInput, TestDirOutput); + + Assert.Equal(5, Directory.GetFiles(TestDirOutput).Length); + } + + [Fact] + public void ProcessingTask_PropertiesAreSettable() + { + var task = new ProcessingTask + { + InputPath = "input.jpg", + OutputPath = "output.jpg", + ImageData = new byte[5, 5] + }; + + Assert.Equal("input.jpg", task.InputPath); + Assert.Equal("output.jpg", task.OutputPath); + Assert.NotNull(task.ImageData); + } + + [Fact] + public void ProcessingResult_PropertiesAreSettable() + { + var result = new ProcessingResult + { + OutputPath = "output.jpg", + ResultData = new byte[5, 5], + ProcessedBy = ProcessorType.GPU + }; + + Assert.Equal("output.jpg", result.OutputPath); + Assert.NotNull(result.ResultData); + Assert.Equal(ProcessorType.GPU, result.ProcessedBy); + } + + [Fact] + public void UnifiedProcessorConfig_DefaultValuesAreCorrect() + { + var config = new UnifiedProcessorConfig(); + + Assert.True(config.CpuWorkers > 0); + Assert.Equal(1, config.GpuWorkers); + Assert.Equal(1, config.ReaderThreads); + Assert.Equal(1, config.WriterThreads); + Assert.NotNull(config.Kernel); + Assert.Equal(EdgeStrategy.Extend, config.Strategy); + } + [Fact] + public void UnifiedProcessor_DisposeMultipleTimes_DoesNotThrow() + { + var config = new UnifiedProcessorConfig + { + CpuWorkers = 1, + GpuWorkers = 0 + }; + + var processor = new UnifiedProcessor(config); + processor.Dispose(); + processor.Dispose(); + } + + [CudaFact] + public void GpuProcessor_DisposeMultipleTimes_DoesNotThrow() + { + var processor = new GpuConvolutionProcessor(); + processor.Dispose(); + processor.Dispose(); + } + + [CudaFact] + public void ConvolveGpu_LargerKernel_ProcessesCorrectly() + { + using var gpuProcessor = new GpuConvolutionProcessor(); + var image = new byte[10, 10]; + for (int i = 0; i < 10; i++) + for (int j = 0; j < 10; j++) + image[i, j] = 50; + + var largeKernel = new float[5, 5]; + for (int i = 0; i < 5; i++) + for (int j = 0; j < 5; j++) + largeKernel[i, j] = 1f / 25f; + + var result = gpuProcessor.ConvolveGpu(image, largeKernel, EdgeStrategy.Extend); + + Assert.NotNull(result); + Assert.Equal(10, result.GetLength(0)); + Assert.Equal(10, result.GetLength(1)); + Assert.InRange(result[5, 5], 45f, 55f); + } + + [Fact] + public void UnifiedProcessor_WithZeroWorkers_IsRejected() + { + CreateTestImageByte(TestDirInput, "test.jpg", 10, 10); + + var config = new UnifiedProcessorConfig + { + CpuWorkers = 0, + GpuWorkers = 0, + ReaderThreads = 1, + WriterThreads = 1 + }; + + Assert.Throws(() => new UnifiedProcessor(config)); + } + + [CudaFact] + public void ConvolveGpu_SharpenKernel_ProducesValidResult() + { + using var gpuProcessor = new GpuConvolutionProcessor(); + var image = new byte[10, 10]; + for (int y = 0; y < 10; y++) + for (int x = 0; x < 10; x++) + image[y, x] = (byte)((x + y) % 2 == 0 ? 100 : 50); + + var result = gpuProcessor.ConvolveGpu(image, Kernels.SharpenFloat, EdgeStrategy.Extend); + + Assert.NotNull(result); + Assert.Equal(10, result.GetLength(0)); + Assert.Equal(10, result.GetLength(1)); + } + + [CudaFact] + public void ConvolveGpu_LaplacianKernel_ProducesValidResult() + { + using var gpuProcessor = new GpuConvolutionProcessor(); + var image = new byte[10, 10]; + for (int y = 0; y < 10; y++) + for (int x = 0; x < 10; x++) + image[y, x] = 100; + + var result = gpuProcessor.ConvolveGpu(image, Kernels.LaplacianFloat, EdgeStrategy.ZeroPadding); + + Assert.NotNull(result); + Assert.Equal(10, result.GetLength(0)); + Assert.Equal(10, result.GetLength(1)); + } + + [Fact] + public void UnifiedProcessor_SingleReaderSingleWriter_ProcessesCorrectly() + { + CreateTestImageByte(TestDirInput, "test1.jpg", 10, 10); + CreateTestImageByte(TestDirInput, "test2.jpg", 10, 10); + + var config = new UnifiedProcessorConfig + { + CpuWorkers = 1, + GpuWorkers = 0, + ReaderThreads = 1, + WriterThreads = 1 + }; + + using var processor = new UnifiedProcessor(config); + processor.ProcessDirectory(TestDirInput, TestDirOutput); + + Assert.Equal(2, Directory.GetFiles(TestDirOutput).Length); + } + + [Fact] + public void ProcessorType_EnumValues_AreAccessible() + { + Assert.Equal(ProcessorType.CPU, ProcessorType.CPU); + Assert.Equal(ProcessorType.GPU, ProcessorType.GPU); + Assert.NotEqual(ProcessorType.CPU, ProcessorType.GPU); + } + [CudaFact] + public void UnifiedProcessor_LargeNumberOfFiles_ProcessesAll() + { + for (int i = 0; i < 10; i++) + { + CreateTestImageByte(TestDirInput, $"test{i:D3}.jpg", 10, 10); + } + + var config = new UnifiedProcessorConfig + { + CpuWorkers = 2, + GpuWorkers = 1, + ReaderThreads = 2, + WriterThreads = 2 + }; + + using var processor = new UnifiedProcessor(config); + processor.ProcessDirectory(TestDirInput, TestDirOutput); + + Assert.Equal(10, Directory.GetFiles(TestDirOutput).Length); + } + + [CudaFact] + public void ConvolveGpu_EdgePixels_ExtendStrategy() + { + using var gpuProcessor = new GpuConvolutionProcessor(); + var image = new byte[5, 5]; + for (int y = 0; y < 5; y++) + for (int x = 0; x < 5; x++) + image[y, x] = (byte)((y == 0 || x == 0 || y == 4 || x == 4) ? 255 : 128); + + var result = gpuProcessor.ConvolveGpu(image, Kernels.BlurBoxFloat, EdgeStrategy.Extend); + + Assert.NotNull(result); + Assert.True(result[0, 0] > 0); + Assert.True(result[4, 4] > 0); + } + + [CudaFact] + public void ConvolveGpu_EdgePixels_ZeroPaddingStrategy() + { + using var gpuProcessor = new GpuConvolutionProcessor(); + var image = new byte[5, 5]; + for (int y = 0; y < 5; y++) + for (int x = 0; x < 5; x++) + image[y, x] = 200; + + var result = gpuProcessor.ConvolveGpu(image, Kernels.BlurBoxFloat, EdgeStrategy.ZeroPadding); + + Assert.NotNull(result); + Assert.True(result[0, 0] < result[2, 2]); + } + + [Fact] + public void UnifiedProcessor_MixedImageSizes_ProcessesAll() + { + CreateTestImageByte(TestDirInput, "small.jpg", 5, 5); + CreateTestImageByte(TestDirInput, "medium.jpg", 10, 10); + CreateTestImageByte(TestDirInput, "large.jpg", 15, 15); + + var config = new UnifiedProcessorConfig + { + CpuWorkers = 1, + GpuWorkers = 0 + }; + + using var processor = new UnifiedProcessor(config); + processor.ProcessDirectory(TestDirInput, TestDirOutput); + + Assert.True(Directory.Exists(TestDirOutput)); + var files = Directory.GetFiles(TestDirOutput); + Assert.Equal(3, files.Length); + } + + [CudaFact] + public void ProcessDirectory_MultipleIdenticalSizeImages_ProcessesAll() + { + for (int i = 0; i < 3; i++) + { + CreateTestImageByte(TestDirInput, $"image{i}.jpg", 20, 20); + } + + GpuConvolutionProcessor.ProcessDirectory(TestDirInput, TestDirOutput, Kernels.SharpenFloat, EdgeStrategy.ZeroPadding); + + Assert.Equal(3, Directory.GetFiles(TestDirOutput).Length); + } + + [CudaFact] + public void UnifiedProcessor_OnlyGpuWorkers_ProcessesCorrectly() + { + CreateTestImageByte(TestDirInput, "gpu1.jpg", 10, 10); + CreateTestImageByte(TestDirInput, "gpu2.jpg", 10, 10); + CreateTestImageByte(TestDirInput, "gpu3.jpg", 10, 10); + + var config = new UnifiedProcessorConfig + { + CpuWorkers = 0, + GpuWorkers = 2, + ReaderThreads = 1, + WriterThreads = 1 + }; + + using var processor = new UnifiedProcessor(config); + processor.ProcessDirectory(TestDirInput, TestDirOutput); + + Assert.Equal(3, Directory.GetFiles(TestDirOutput).Length); + } + + [CudaFact] + public void UnifiedProcessor_HighParallelism_ProcessesCorrectly() + { + for (int i = 0; i < 8; i++) + { + CreateTestImageByte(TestDirInput, $"parallel{i}.jpg", 10, 10); + } + + var config = new UnifiedProcessorConfig + { + CpuWorkers = 4, + GpuWorkers = 1, + ReaderThreads = 4, + WriterThreads = 4 + }; + + using var processor = new UnifiedProcessor(config); + processor.ProcessDirectory(TestDirInput, TestDirOutput); + + Assert.Equal(8, Directory.GetFiles(TestDirOutput).Length); + } + + [CudaFact] + public void ConvolveGpu_AllKernelTypes_Work() + { + using var gpuProcessor = new GpuConvolutionProcessor(); + var image = new byte[10, 10]; + for (int y = 0; y < 10; y++) + for (int x = 0; x < 10; x++) + image[y, x] = 100; + + var blurResult = gpuProcessor.ConvolveGpu(image, Kernels.BlurBoxFloat, EdgeStrategy.Extend); + var sharpenResult = gpuProcessor.ConvolveGpu(image, Kernels.SharpenFloat, EdgeStrategy.Extend); + var laplacianResult = gpuProcessor.ConvolveGpu(image, Kernels.LaplacianFloat, EdgeStrategy.Extend); + + Assert.NotNull(blurResult); + Assert.NotNull(sharpenResult); + Assert.NotNull(laplacianResult); + } + [Fact] + public void UnifiedProcessor_CorruptImage_IsRejectedDuringMemoryEstimate() + { + Directory.CreateDirectory(TestDirInput); + File.WriteAllText(Path.Combine(TestDirInput, "corrupt.jpg"), "not an image data"); + + var config = new UnifiedProcessorConfig { CpuWorkers = 1, GpuWorkers = 0 }; + using var processor = new UnifiedProcessor(config); + + Assert.Throws( + () => processor.ProcessDirectory(TestDirInput, TestDirOutput)); + } + + [CudaFact] + public void GpuProcessor_ProcessDirectory_CorruptImage() + { + Directory.CreateDirectory(TestDirInput); + File.WriteAllText(Path.Combine(TestDirInput, "corrupt.jpg"), "not an image data"); + + Assert.Throws(() => + GpuConvolutionProcessor.ProcessDirectory(TestDirInput, TestDirOutput, Kernels.BlurBoxFloat, EdgeStrategy.Extend)); + } + } +} \ No newline at end of file diff --git a/ImageConvolution.Tests/ImageConvolution.Tests.csproj b/ImageConvolution.Tests/ImageConvolution.Tests.csproj new file mode 100644 index 0000000..5e99567 --- /dev/null +++ b/ImageConvolution.Tests/ImageConvolution.Tests.csproj @@ -0,0 +1,32 @@ + + + + net9.0 + enable + enable + false + true + + **/GpuConvolutionProcessor.cs + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ImageConvolution.Tests/MeasurementExperimentTests.cs b/ImageConvolution.Tests/MeasurementExperimentTests.cs new file mode 100644 index 0000000..1730596 --- /dev/null +++ b/ImageConvolution.Tests/MeasurementExperimentTests.cs @@ -0,0 +1,130 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; + +using ImageConvolution; + +using Xunit; + +namespace ImageConvolution.Tests; + +public sealed class MeasurementExperimentTests +{ + [Fact] + public void ByteLayoutReportChecksAllStorageAndPaddingVariants() + { + using var workspace = new SmallImageWorkspace(); + string report = Path.Combine(workspace.Root, "byte-layout"); + ByteLayoutExperiment.Run(["--byte-layout", workspace.Input, report, "2"]); + + string summary = File.ReadAllText(Path.Combine(report, "summary.txt")); + string[] lines = File.ReadAllLines(Path.Combine(report, "times.csv")); + Assert.Contains("ColumnStorage-Columns: median=", summary); + Assert.Contains("Columns-P64: median=", summary); + Assert.Equal(21, lines.Length); + Assert.All(lines.Skip(1), line => Assert.Equal(3, line.Split(',').Length)); + } + + [Fact] + public void ByteLayoutHardwareWorkloadRecordsTheChosenVariant() + { + using var workspace = new SmallImageWorkspace(); + string report = Path.Combine(workspace.Root, "hardware"); + ByteLayoutExperiment.Run(["--byte-layout", workspace.Input, report, "--hardware", "ColumnStorage-Columns", "2"]); + + using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(report, "workload.json"))); + JsonElement root = document.RootElement; + Assert.Equal("ColumnStorage-Columns", root.GetProperty("variant").GetString()); + Assert.Equal(2, root.GetProperty("iterations").GetInt32()); + Assert.Equal(9, root.GetProperty("width").GetInt32()); + Assert.Equal(7, root.GetProperty("height").GetInt32()); + } + + [Fact] + public void ParallelLocalityControlVerifiesOutputsAndWritesFiveComparisons() + { + using var workspace = new SmallImageWorkspace(); + string reports = Path.Combine(Environment.CurrentDirectory, "MeasurementResults"); + string[] before = Directory.Exists(reports) + ? Directory.GetDirectories(reports, "LocalityParallelControl_*") + : []; + LocalityExperiment.ParallelControl(workspace.Input, partitions: 2, measuredRuns: 2); + string folder = Assert.Single(Directory.GetDirectories(reports, "LocalityParallelControl_*").Except(before)); + + try + { + string summary = File.ReadAllText(Path.Combine(folder, "summary.txt")); + string[] csv = File.ReadAllLines(Path.Combine(folder, "times.csv")); + Assert.Contains("Все выходы побитно совпадают", summary); + Assert.Contains("EqualPartitionsTiledColumns32", summary); + Assert.Equal(11, csv.Length); + } + finally + { + Directory.Delete(folder, recursive: true); + } + } + + [Theory] + [InlineData("Rows")] + [InlineData("Columns")] + [InlineData("BlockedColumns")] + [InlineData("ParallelRows")] + [InlineData("ParallelColumns")] + public void LocalityHardwareWorkloadRecordsRequestedTraversal(string traversal) + { + using var workspace = new SmallImageWorkspace(); + string report = Path.Combine(workspace.Root, traversal + ".json"); + LocalityExperiment.HardwareWorkload(["--locality-profile", traversal, workspace.Input, "2", report]); + + using var document = JsonDocument.Parse(File.ReadAllText(report)); + Assert.Equal(traversal, document.RootElement.GetProperty("variant").GetString()); + Assert.Equal(2, document.RootElement.GetProperty("iterations").GetInt32()); + } + + [Fact] + public void ExperimentRunnerWritesOneRecordPerMeasuredRun() + { + int calls = 0; + int previousMode = PerformanceProfiler.Mode; + PerformanceProfiler.Mode = 0; + string name = "ci-smoke-" + Guid.NewGuid().ToString("N"); + string folder = Path.Combine(ExperimentRunner.ResultsDirectory, name); + try + { + ExperimentRunner.Run(name, () => calls++, warmupRuns: 2); + string[] rows = File.ReadAllLines(Path.Combine(folder, "times.csv")); + using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(folder, "summary.json"))); + Assert.Equal(27, calls); + Assert.Equal(26, rows.Length); + Assert.Equal(25, document.RootElement.GetProperty("measuredRuns").GetInt32()); + Assert.Equal(0, document.RootElement.GetProperty("profilerMode").GetInt32()); + Assert.Equal(25, Directory.GetFiles(folder, "run-*.json").Length); + } + finally + { + PerformanceProfiler.Mode = previousMode; + if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true); + } + } + + private sealed class SmallImageWorkspace : IDisposable + { + public string Root { get; } = Path.Combine(Path.GetTempPath(), "imageconv-ci-" + Guid.NewGuid().ToString("N")); + public string Input => Path.Combine(Root, "input.png"); + + public SmallImageWorkspace() + { + Directory.CreateDirectory(Root); + var pixels = new byte[7, 9]; + for (int y = 0; y < 7; y++) + for (int x = 0; x < 9; x++) + pixels[y, x] = (byte)((x * 17 + y * 23) % 256); + ImageIO.SaveImageByte(pixels, Input); + } + + public void Dispose() => Directory.Delete(Root, recursive: true); + } +} \ No newline at end of file diff --git a/ImageConvolution.Tests/TestAssembly.cs b/ImageConvolution.Tests/TestAssembly.cs new file mode 100644 index 0000000..41a898e --- /dev/null +++ b/ImageConvolution.Tests/TestAssembly.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] \ No newline at end of file diff --git a/ImageConvolution/AgentProcessor.cs b/ImageConvolution/AgentProcessor.cs new file mode 100644 index 0000000..126985e --- /dev/null +++ b/ImageConvolution/AgentProcessor.cs @@ -0,0 +1,107 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.ExceptionServices; + +namespace ImageConvolution; + +public class ImageToProcess +{ + public string FilePath { get; set; } = ""; + public byte[,] ImageData { get; set; } = new byte[0, 0]; +} + +public class ImageResult +{ + public string OriginalFileName { get; set; } = ""; + public byte[,] ProcessedData { get; set; } = new byte[0, 0]; +} + +public class AgentProcessor +{ + public static void ProcessImagesWithAgents(string inputDir, string outputDir, int workerCount, + int queueCapacity = 4, long memoryBudgetBytes = 0, int readerCount = 1, int writerCount = 1) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(workerCount); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(queueCapacity); + ArgumentOutOfRangeException.ThrowIfNegative(memoryBudgetBytes); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(readerCount); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(writerCount); + if (!Directory.Exists(inputDir)) return; + ImageFiles.ValidateDirectories(inputDir, outputDir); + Directory.CreateDirectory(outputDir); + var stopwatch = Stopwatch.StartNew(); + var files = ImageFiles.GetFiles(inputDir); + int limit = PerformanceProfiler.Measure("setup.memory_limit", () => ImageFiles.GetInFlightLimit(files, memoryBudgetBytes, ImageFiles.EstimatedInFlightBytesPerPixel)); + PerformanceProfiler.Set("pipeline", new + { + inputDir, + files = files.Length, + cpuWorkers = workerCount, + gpuWorkers = 0, + readers = readerCount, + writers = writerCount, + queueCapacity, + inFlightLimit = limit, + pixelType = "byte", + memoryBudgetBytes + }); + var filesQueue = new ConcurrentQueue(files); + using var slots = new SemaphoreSlim(limit); + using var input = new BlockingCollection(queueCapacity); + using var output = new BlockingCollection(queueCapacity); + using var cancellation = new CancellationTokenSource(); + var token = cancellation.Token; + Exception? failure = null; + Task Start(Action action) => Task.Factory.StartNew(() => + { + try { action(); } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + catch (Exception ex) + { + Interlocked.CompareExchange(ref failure, ex, null); + cancellation.Cancel(); + } + }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + var readers = Enumerable.Range(0, readerCount).Select(_ => Start(() => + { + while (filesQueue.TryDequeue(out var file)) + { + using (PerformanceProfiler.Measure("reader.wait_slot")) slots.Wait(token); + var item = new ImageToProcess + { + FilePath = file, + ImageData = PerformanceProfiler.Measure("reader.load", () => ImageIO.LoadAsGrayscaleByte(file)) + }; + using (PerformanceProfiler.Measure("reader.enqueue")) input.Add(item, token); + } + })).ToArray(); + var readerCompletion = Task.WhenAll(readers).ContinueWith(_ => input.CompleteAdding(), TaskScheduler.Default); + var workers = Enumerable.Range(0, workerCount).Select(_ => Start(() => + { + foreach (var item in PerformanceProfiler.Consume(input, "cpu.wait_input", token)) + { + var result = new ImageResult + { + OriginalFileName = Path.GetFileName(item.FilePath), + ProcessedData = ConvolutionProcessor.Convolve(item.ImageData, Kernels.BlurBox) + }; + item.ImageData = new byte[0, 0]; + using (PerformanceProfiler.Measure("cpu.enqueue_result")) output.Add(result, token); + } + })).ToArray(); + var completion = Task.WhenAll(workers).ContinueWith(_ => output.CompleteAdding(), TaskScheduler.Default); + var writers = Enumerable.Range(0, writerCount).Select(_ => Start(() => + { + foreach (var result in PerformanceProfiler.Consume(output, "writer.wait_input", token)) + { + using (PerformanceProfiler.Measure("writer.save")) + ImageIO.SaveImageByte(result.ProcessedData, Path.Combine(outputDir, result.OriginalFileName)); + result.ProcessedData = new byte[0, 0]; + slots.Release(); + } + })).ToArray(); + Task.WaitAll(writers.Append(readerCompletion).Append(completion).ToArray()); + if (failure != null) ExceptionDispatchInfo.Capture(failure).Throw(); + Console.WriteLine($"Обработка конвейером ({workerCount} вычислителей, {readerCount} читателей, {writerCount} писателей): {files.Length} файлов за {stopwatch.Elapsed.TotalMilliseconds:F1} мс."); + } +} \ No newline at end of file diff --git a/ImageConvolution/BatchProcessor.cs b/ImageConvolution/BatchProcessor.cs new file mode 100644 index 0000000..44b3d48 --- /dev/null +++ b/ImageConvolution/BatchProcessor.cs @@ -0,0 +1,128 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; + +namespace ImageConvolution +{ + public enum ParallelStrategy + { + Sequential, + ParallelByRows, + ParallelByColumns + } + + public class BatchProcessor + { + public static void ProcessImagesSequential(string inputDirectory, string outputDirectory) + { + if (!Directory.Exists(inputDirectory)) return; + ImageFiles.ValidateDirectories(inputDirectory, outputDirectory); + Directory.CreateDirectory(outputDirectory); + string[] files = ImageFiles.GetFiles(inputDirectory); + PerformanceProfiler.Set("batch", new + { + inputDirectory, + files = files.Length, + parallelFiles = 1, + strategy = ParallelStrategy.Sequential.ToString(), + pixelType = "byte", + execution = "load -> CPU convolution -> save; sequential files" + }); + + foreach (string file in files) + { + byte[,] image = PerformanceProfiler.Measure("reader.load", () => ImageIO.LoadAsGrayscaleByte(file)); + byte[,] result = ConvolutionProcessor.Convolve(image, Kernels.BlurBox, EdgeStrategy.Extend); + using (PerformanceProfiler.Measure("writer.save")) + ImageIO.SaveImageByte(result, Path.Combine(outputDirectory, Path.GetFileName(file))); + } + } + + public static void ProcessImagesNaiveParallel(string inputDirectory, string outputDirectory, ParallelStrategy strategy, + int maxDegreeOfParallelism = 0, long memoryBudgetBytes = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(maxDegreeOfParallelism); + ArgumentOutOfRangeException.ThrowIfNegative(memoryBudgetBytes); + if (!Enum.IsDefined(strategy)) throw new ArgumentOutOfRangeException(nameof(strategy)); + if (!Directory.Exists(inputDirectory)) return; + ImageFiles.ValidateDirectories(inputDirectory, outputDirectory); + Directory.CreateDirectory(outputDirectory); + string[] files = ImageFiles.GetFiles(inputDirectory); + + var options = new ParallelOptions { MaxDegreeOfParallelism = maxDegreeOfParallelism == 0 ? Environment.ProcessorCount : maxDegreeOfParallelism }; + + int gen0Start = GC.CollectionCount(0); + int gen1Start = GC.CollectionCount(1); + int gen2Start = GC.CollectionCount(2); + + using var process = Process.GetCurrentProcess(); + TimeSpan cpuTimeStart = process.TotalProcessorTime; + var sw = Stopwatch.StartNew(); + options.MaxDegreeOfParallelism = Math.Min(options.MaxDegreeOfParallelism, ImageFiles.GetInFlightLimit(files, memoryBudgetBytes, ImageFiles.EstimatedInFlightBytesPerPixel)); + PerformanceProfiler.Set("batch", new + { + inputDirectory, + files = files.Length, + parallelFiles = options.MaxDegreeOfParallelism, + strategy = strategy.ToString(), + pixelType = "byte", + memoryBudgetBytes + }); + Console.WriteLine($"Параллельных файлов: {options.MaxDegreeOfParallelism}; свёртка: {strategy}"); + + Parallel.ForEach(files, options, currentFile => + { + string fileName = Path.GetFileName(currentFile); + string savePath = Path.Combine(outputDirectory, fileName); + + byte[,] imageData = PerformanceProfiler.Measure("reader.load", () => ImageIO.LoadAsGrayscaleByte(currentFile)); + byte[,] result; + + switch (strategy) + { + case ParallelStrategy.Sequential: + result = ConvolutionProcessor.Convolve(imageData, Kernels.BlurBox); + break; + + case ParallelStrategy.ParallelByRows: + result = ParallelConvolutionProcessor.ConvolveParallel(imageData, Kernels.BlurBox); + break; + + case ParallelStrategy.ParallelByColumns: + result = ParallelConvolutionProcessor.ConvolveParallelByColumns(imageData, Kernels.BlurBox); + break; + + default: + throw new ArgumentException("Unknown strategy"); + } + + using (PerformanceProfiler.Measure("writer.save")) ImageIO.SaveImageByte(result, savePath); + }); + + sw.Stop(); + TimeSpan cpuTimeEnd = process.TotalProcessorTime; + + int gc0 = GC.CollectionCount(0) - gen0Start; + int gc1 = GC.CollectionCount(1) - gen1Start; + int gc2 = GC.CollectionCount(2) - gen2Start; + + double realTimeMs = sw.Elapsed.TotalMilliseconds; + double cpuTimeMs = (cpuTimeEnd - cpuTimeStart).TotalMilliseconds; + + string strategyName = strategy switch + { + ParallelStrategy.Sequential => "Последовательная", + ParallelStrategy.ParallelByRows => "Параллельная по строкам (Y)", + ParallelStrategy.ParallelByColumns => "Параллельная по столбцам (X)", + _ => "Неизвестная" + }; + + Console.WriteLine($"\n--- РЕЗУЛЬТАТЫ ПРОФИЛИРОВАНИЯ ({strategyName}) ---"); + Console.WriteLine($"Реальное время: {realTimeMs:F1} мс"); + Console.WriteLine($"Процессорное время (сумма по всем ядрам): {cpuTimeMs:F1} мс"); + Console.WriteLine($"Сборки мусора (GC): Gen0={gc0}, Gen1={gc1}, Gen2={gc2}"); + Console.WriteLine("----------------------------------------------------------\n"); + } + } +} \ No newline at end of file diff --git a/ImageConvolution/ByteLayoutExperiment.cs b/ImageConvolution/ByteLayoutExperiment.cs new file mode 100644 index 0000000..a81066c --- /dev/null +++ b/ImageConvolution/ByteLayoutExperiment.cs @@ -0,0 +1,179 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; + +namespace ImageConvolution; + +// A controlled experiment. Padding is outside the logical image and is never convolved. +public static class ByteLayoutExperiment +{ + private readonly record struct Variant(string Name, bool ColumnStorage, bool ColumnTraversal, int Padding); + + private static readonly Variant[] Variants = + [ + new("Rows-P0", false, false, 0), + new("Columns-P0", false, true, 0), + new("Columns-P1", false, true, 1), + new("Columns-P3", false, true, 3), + new("Columns-P8", false, true, 8), + new("Columns-P16", false, true, 16), + new("Columns-P64", false, true, 64), + new("Columns-P128", false, true, 128), + new("ColumnStorage-Columns", true, true, 0), + new("ColumnStorage-Rows", true, false, 0) + ]; + + public static void Run(string[] args) + { + if (args.Length < 3 || args.Length > 6) + throw new ArgumentException("--byte-layout путь_к_изображению папка_отчёта [число_измерений] | --byte-layout путь папка --hardware вариант [число_повторов]"); + string path = Path.GetFullPath(args[1]); + string reportDirectory = Path.GetFullPath(args[2]); + Directory.CreateDirectory(reportDirectory); + var image = ImageIO.LoadAsGrayscaleByte(path); + int height = image.GetLength(0), width = image.GetLength(1); + var inputs = new Dictionary(); + foreach (int padding in Variants.Where(v => !v.ColumnStorage).Select(v => v.Padding).Distinct()) + { + var data = new byte[checked(height * (width + padding))]; + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) data[y * (width + padding) + x] = image[y, x]; + inputs.Add(padding, data); + } + var columnInput = new byte[checked(width * height)]; + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) columnInput[x * height + y] = image[y, x]; + + byte[] Apply(Variant variant) => Convolve(variant.ColumnStorage ? columnInput : inputs[variant.Padding], + width, height, variant.ColumnStorage, variant.ColumnTraversal, variant.Padding); + + var expected = ConvolutionProcessor.Convolve(image, Kernels.BlurBox, EdgeStrategy.Extend); + void Check(Variant variant) + { + byte[] actual = Apply(variant); + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) + { + int index = variant.ColumnStorage ? x * height + y : y * (width + variant.Padding) + x; + if (actual[index] != expected[y, x]) + throw new InvalidOperationException($"{variant.Name}: результат отличается в ({x},{y})"); + } + } + + if (args.Length >= 4 && args[3] == "--hardware") + { + if (args.Length < 5) throw new ArgumentException("Нужно указать вариант для аппаратного профиля"); + Variant variant = Variants.Single(v => v.Name == args[4]); + int iterations = args.Length == 6 ? int.Parse(args[5]) : 30; + if (iterations < 1) throw new ArgumentOutOfRangeException(nameof(iterations)); + Check(variant); + for (int i = 0; i < 5; i++) GC.KeepAlive(Apply(variant)); + DateTime startedUtc = DateTime.UtcNow; + var timer = Stopwatch.StartNew(); + long checksum = 0; + for (int i = 0; i < iterations; i++) + { + byte[] result = Apply(variant); + checksum += result[variant.ColumnStorage ? (i % width) * height + i % height : (i % height) * (width + variant.Padding) + i % width]; + } + timer.Stop(); + File.WriteAllText(Path.Combine(reportDirectory, "workload.json"), JsonSerializer.Serialize(new + { + variant = variant.Name, + processId = Environment.ProcessId, + startedUtc, + endedUtc = DateTime.UtcNow, + iterations, + width, + height, + pixelType = "byte", + inputPadding = variant.Padding, + outputPadding = variant.Padding, + elapsedMilliseconds = timer.Elapsed.TotalMilliseconds, + checksum, + scope = "Convolution and result allocation; JPEG decode and five warmups excluded" + }, new JsonSerializerOptions { WriteIndented = true })); + return; + } + + int measuredRuns = args.Length == 4 ? int.Parse(args[3]) : 25; + if (measuredRuns < 1) throw new ArgumentOutOfRangeException(nameof(measuredRuns)); + foreach (Variant variant in Variants) Check(variant); + var samples = Variants.ToDictionary(v => v.Name, _ => new List(measuredRuns)); + var csv = new StringBuilder("run,variant,elapsed_ms\n"); + const int warmups = 5; + for (int run = -warmups; run < measuredRuns; run++) + { + for (int offset = 0; offset < Variants.Length; offset++) + { + Variant variant = Variants[(offset + run + Variants.Length) % Variants.Length]; + var timer = Stopwatch.StartNew(); + byte[] result = Apply(variant); + timer.Stop(); + GC.KeepAlive(result); + if (run < 0) continue; + double ms = timer.Elapsed.TotalMilliseconds; + samples[variant.Name].Add(ms); + csv.AppendLine(FormattableString.Invariant($"{run + 1},{variant.Name},{ms:F6}")); + } + if (run >= 0 && (run + 1) % 5 == 0) Console.WriteLine($"Byte layout: {run + 1}/{measuredRuns}"); + } + File.WriteAllText(Path.Combine(reportDirectory, "times.csv"), csv.ToString()); + var summary = new StringBuilder(); + summary.AppendLine($"{width}x{height}; byte pixels; BoxBlur 3x3; Extend; {warmups} warmups; {measuredRuns} measurements per variant."); + summary.AppendLine("Same logical pixels and convolution; every variant matched the main CPU implementation bit for bit."); + summary.AppendLine("Padding is unused space after each input/output row. Timing includes result allocation and convolution, excludes JPEG I/O."); + summary.AppendLine("Padding of 64 byte pixels equals the old 8-double (64-byte) padding in physical size."); + foreach (Variant variant in Variants) + { + double[] sorted = samples[variant.Name].Order().ToArray(); + double median = sorted.Length % 2 == 1 ? sorted[sorted.Length / 2] : + (sorted[sorted.Length / 2 - 1] + sorted[sorted.Length / 2]) / 2; + summary.AppendLine(FormattableString.Invariant($"{variant.Name}: median={median:F3} ms; min={sorted[0]:F3}; max={sorted[^1]:F3}")); + } + File.WriteAllText(Path.Combine(reportDirectory, "summary.txt"), summary.ToString()); + Console.WriteLine(summary); + } + + private static byte[] Convolve(byte[] source, int width, int height, bool columnStorage, bool columnTraversal, int padding) + { + int strideY = columnStorage ? 1 : width + padding; + int strideX = columnStorage ? height : 1; + var target = new byte[source.Length]; + var weights = new float[9]; + var offsets = new int[9]; + for (int ky = 0, i = 0; ky < 3; ky++) + for (int kx = 0; kx < 3; kx++, i++) + { + weights[i] = Kernels.BlurBox[ky, kx]; + offsets[i] = (1 - ky) * strideY + (1 - kx) * strideX; + } + void Line(int line) + { + int length = columnTraversal ? height : width; + for (int position = 0; position < length; position++) + { + int y = columnTraversal ? position : line; + int x = columnTraversal ? line : position; + int center = y * strideY + x * strideX; + float sum = 0; + if (y > 0 && y < height - 1 && x > 0 && x < width - 1) + for (int i = 0; i < 9; i++) sum += source[center + offsets[i]] * weights[i]; + else + for (int ky = 0, i = 0; ky < 3; ky++) + for (int kx = 0; kx < 3; kx++, i++) + { + int sy = Math.Clamp(y + 1 - ky, 0, height - 1); + int sx = Math.Clamp(x + 1 - kx, 0, width - 1); + sum += source[sy * strideY + sx * strideX] * weights[i]; + } + target[center] = ImageIO.ToByte(sum); + } + } + if (columnTraversal) + Parallel.For(0, width, Line); + else + Parallel.For(0, height, Line); + return target; + } +} \ No newline at end of file diff --git a/ImageConvolution/ConvolutionBenchmarks.cs b/ImageConvolution/ConvolutionBenchmarks.cs new file mode 100644 index 0000000..6917d35 --- /dev/null +++ b/ImageConvolution/ConvolutionBenchmarks.cs @@ -0,0 +1,76 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +using BenchmarkDotNet.Attributes; + +namespace ImageConvolution +{ + [MemoryDiagnoser] + [IterationCount(ExperimentRunner.MeasuredRuns)] + [WarmupCount(3)] + [Outliers(Perfolizer.Mathematics.OutlierDetection.OutlierMode.DontRemove)] + [ExcludeFromCodeCoverage] + public class ConvolutionBenchmarks + { + private byte[,] image = null!; + private GpuConvolutionProcessor gpuProcessor = null!; + + [Params(512, 1024)] + public int ImageSize { get; set; } + + [GlobalSetup] + public void Setup() + { + image = new byte[ImageSize, ImageSize]; + + var rand = new Random(42); + for (int y = 0; y < ImageSize; y++) + { + for (int x = 0; x < ImageSize; x++) + { + image[y, x] = (byte)rand.Next(0, 256); + } + } + + gpuProcessor = new GpuConvolutionProcessor(); + + _ = gpuProcessor.ConvolveGpu(image, Kernels.BlurBox, EdgeStrategy.Extend); + } + + [GlobalCleanup] + public void Cleanup() + { + gpuProcessor?.Dispose(); + } + + [Benchmark(Baseline = true, Description = "1. CPU (Один поток)")] + public byte[,] CpuSequential() + { + return ConvolutionProcessor.Convolve(image, Kernels.BlurBox, EdgeStrategy.Extend); + } + + [Benchmark(Description = "2. CPU (Parallel.For)")] + public byte[,] CpuParallel() + { + return ParallelConvolutionProcessor.ConvolveParallel(image, Kernels.BlurBox, EdgeStrategy.Extend); + } + + [Benchmark(Description = "CPU (Parallel.For по столбцам)")] + public byte[,] CpuParallelColumns() => ParallelConvolutionProcessor.ConvolveParallelByColumns(image, Kernels.BlurBox); + + [Benchmark(Description = "ImageSharp BoxBlur 3x3 + преобразования")] + public byte[,] ImageSharpBox() => LibraryProcessor.ConvolveBox(image); + + [Benchmark(Description = "3. GPU (PCIe + Math)")] + public byte[,] GpuFullCycle() + { + return gpuProcessor.ConvolveGpu(image, Kernels.BlurBox, EdgeStrategy.Extend); + } + + [Benchmark(Description = "4. GPU (запуск + ядро + синхронизация)")] + public void GpuMathOnly() + { + gpuProcessor.ConvolveGpuMathOnly_ForBenchmark(Kernels.BlurBox, EdgeStrategy.Extend); + } + } +} \ No newline at end of file diff --git a/ImageConvolution/ConvolutionCore.cs b/ImageConvolution/ConvolutionCore.cs new file mode 100644 index 0000000..f8b1aa8 --- /dev/null +++ b/ImageConvolution/ConvolutionCore.cs @@ -0,0 +1,116 @@ +using System.Runtime.InteropServices; + +namespace ImageConvolution; + +internal static class ConvolutionCore +{ + public static byte[,] Convolve(byte[,] image, float[,] kernel, EdgeStrategy edge, ParallelStrategy parallel, + SequentialTraversal traversal = SequentialTraversal.Rows, int blockSize = 32, int partitionCount = 0) + { + ConvolutionValidation.Validate(image, kernel, edge); + if (!Enum.IsDefined(parallel)) throw new ArgumentOutOfRangeException(nameof(parallel)); + if (!Enum.IsDefined(traversal)) throw new ArgumentOutOfRangeException(nameof(traversal)); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(blockSize); + ArgumentOutOfRangeException.ThrowIfNegative(partitionCount); + if (partitionCount > 0 && parallel == ParallelStrategy.Sequential) + throw new ArgumentException("Контрольные разделы требуют параллельного выполнения", nameof(partitionCount)); + if (parallel != ParallelStrategy.Sequential && traversal != SequentialTraversal.Rows + && !(partitionCount > 0 && parallel == ParallelStrategy.ParallelByColumns && traversal == SequentialTraversal.BlockedColumns)) + throw new ArgumentException("Контрольный порядок обхода требует последовательного выполнения", nameof(traversal)); + using var measurement = PerformanceProfiler.Measure(PerformanceProfiler.IsActive ? "cpu.convolve." + parallel : ""); + PerformanceProfiler.DescribeArray(image); + if (PerformanceProfiler.IsActive) + PerformanceProfiler.Set("kernel", new { height = kernel.GetLength(0), width = kernel.GetLength(1), edge = edge.ToString() }); + int height = image.GetLength(0), width = image.GetLength(1); + int kh = kernel.GetLength(0), kw = kernel.GetLength(1); + int oy = kh / 2, ox = kw / 2; + var result = new byte[height, width]; + var weights = new float[kernel.Length]; + var offsets = new int[kernel.Length]; + bool hasInterior = height >= kh && width >= kw; + for (int ky = 0, i = 0; ky < kh; ky++) + for (int kx = 0; kx < kw; kx++, i++) + { + weights[i] = kernel[ky, kx]; + offsets[i] = hasInterior ? (oy - ky) * width + ox - kx : 0; + } + void Line(int line, bool columns, int start = 0, int end = int.MaxValue) + { + var source = MemoryMarshal.CreateReadOnlySpan(ref image[0, 0], image.Length); + var target = MemoryMarshal.CreateSpan(ref result[0, 0], result.Length); + int count = Math.Min(columns ? height : width, end); + for (int position = start; position < count; position++) + { + int y = columns ? position : line, x = columns ? line : position; + int center = y * width + x; + float sum = 0; + if (hasInterior && y >= oy && y < height - oy && x >= ox && x < width - ox) + { + for (int i = 0; i < weights.Length; i++) sum += source[center + offsets[i]] * weights[i]; + } + else + { + for (int ky = 0, i = 0; ky < kh; ky++) + for (int kx = 0; kx < kw; kx++, i++) + { + int sy = y + oy - ky, sx = x + ox - kx; + byte value = 0; + if (edge == EdgeStrategy.Extend) + value = source[Math.Clamp(sy, 0, height - 1) * width + Math.Clamp(sx, 0, width - 1)]; + else if ((uint)sy < (uint)height && (uint)sx < (uint)width) + value = source[sy * width + sx]; + sum += value * weights[i]; + } + } + target[center] = ImageIO.ToByte(sum); + } + } + if (partitionCount > 0) + { + bool columns = parallel == ParallelStrategy.ParallelByColumns; + int lines = columns ? width : height; + Parallel.For(0, partitionCount, new ParallelOptions { MaxDegreeOfParallelism = partitionCount }, partition => + { + int first = (int)((long)partition * lines / partitionCount); + int last = (int)((long)(partition + 1) * lines / partitionCount); + if (columns && traversal == SequentialTraversal.BlockedColumns) + { + for (int y0 = 0; y0 < height; y0 += blockSize) + { + int y1 = Math.Min(y0 + blockSize, height); + for (int x0 = first; x0 < last; x0 += blockSize) + { + int x1 = Math.Min(x0 + blockSize, last); + for (int x = x0; x < x1; x++) Line(x, true, y0, y1); + } + } + } + else + for (int line = first; line < last; line++) Line(line, columns); + }); + } + else if (parallel == ParallelStrategy.ParallelByRows) + Parallel.For(0, height, y => Line(y, false)); + else if (parallel == ParallelStrategy.ParallelByColumns) + Parallel.For(0, width, x => Line(x, true)); + else if (traversal == SequentialTraversal.Columns) + for (int x = 0; x < width; x++) Line(x, true); + else if (traversal == SequentialTraversal.BlockedColumns) + { + for (int y0 = 0; y0 < height;) + { + int y1 = y0 + Math.Min(blockSize, height - y0); + for (int x0 = 0; x0 < width;) + { + int x1 = x0 + Math.Min(blockSize, width - x0); + for (int x = x0; x < x1; x++) Line(x, true, y0, y1); + x0 = x1; + } + y0 = y1; + } + } + else + for (int y = 0; y < height; y++) Line(y, false); + return result; + } +} \ No newline at end of file diff --git a/ImageConvolution/ConvolutionValidation.cs b/ImageConvolution/ConvolutionValidation.cs new file mode 100644 index 0000000..48fc415 --- /dev/null +++ b/ImageConvolution/ConvolutionValidation.cs @@ -0,0 +1,57 @@ +namespace ImageConvolution; + +internal static class ConvolutionValidation +{ + public static void Validate(T[,] image, TKernel[,] kernel, EdgeStrategy strategy) + where TKernel : System.Numerics.IFloatingPointIeee754 + { + ArgumentNullException.ThrowIfNull(image); + if (image.GetLength(0) == 0 || image.GetLength(1) == 0) + throw new ArgumentException("Изображение не должно быть пустым", nameof(image)); + ValidateKernel(kernel, strategy); + } + + public static void ValidateKernel(T[,] kernel, EdgeStrategy strategy) + where T : System.Numerics.IFloatingPointIeee754 + { + ArgumentNullException.ThrowIfNull(kernel); + if (kernel.GetLength(0) % 2 != 1 || kernel.GetLength(1) % 2 != 1) + throw new ArgumentException("Размеры ядра должны быть положительными и нечётными", nameof(kernel)); + foreach (T value in kernel) + if (!T.IsFinite(value)) throw new ArgumentException("Коэффициенты ядра должны быть конечными", nameof(kernel)); + if (!Enum.IsDefined(strategy)) throw new ArgumentOutOfRangeException(nameof(strategy)); + } +} + +internal static class ImageFiles +{ + // Two byte matrices plus temporary decoded RGBA and output image buffers. + // This is a conservative concurrency estimate, not the stored pixel type. + public const int EstimatedInFlightBytesPerPixel = 8; + private static readonly HashSet Extensions = new(StringComparer.OrdinalIgnoreCase) + { + ".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp" + }; + + public static string[] GetFiles(string directory) => Directory.EnumerateFiles(directory) + .Where(f => Extensions.Contains(Path.GetExtension(f))).OrderBy(f => f, StringComparer.Ordinal).ToArray(); + + public static int GetInFlightLimit(string[] files, long budget, int bytesPerPixel) + { + if (budget == 0) budget = Math.Clamp(GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / 8, 256L * 1024 * 1024, 2L * 1024 * 1024 * 1024); + long largest = 1; + foreach (var file in files) + { + var info = SixLabors.ImageSharp.Image.Identify(file); + largest = Math.Max(largest, checked((long)info.Width * info.Height * bytesPerPixel)); + } + return (int)Math.Clamp(budget / largest, 1, Math.Max(1, files.Length)); + } + + public static void ValidateDirectories(string input, string output) + { + if (string.Equals(Path.TrimEndingDirectorySeparator(Path.GetFullPath(input)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(output)), StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Исходная и выходная папки должны различаться", nameof(output)); + } +} \ No newline at end of file diff --git a/ImageConvolution/ExperimentRunner.cs b/ImageConvolution/ExperimentRunner.cs new file mode 100644 index 0000000..ad8cdd1 --- /dev/null +++ b/ImageConvolution/ExperimentRunner.cs @@ -0,0 +1,98 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace ImageConvolution; + +public static class ExperimentRunner +{ + public const int MeasuredRuns = 25; + static string? root; + public static string ResultsDirectory => root ??= Path.Combine(Environment.CurrentDirectory, "MeasurementResults", + DateTime.Now.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString("N")[..8]); + static readonly JsonSerializerOptions jsonOptions = new() { WriteIndented = true }; + sealed record RunResult(int Run, double ElapsedMilliseconds, string? Error, PerformanceProfiler.Snapshot? Profile); + + public static void Run(string name, Action action, int warmupRuns = 1) + { + string folder = Path.Combine(ResultsDirectory, name); + Directory.CreateDirectory(folder); + Console.WriteLine($"\n--- {name}: прогрев {warmupRuns}, измерений {MeasuredRuns} ---"); + for (int i = 0; i < warmupRuns; i++) action(); + var results = new List(); + for (int i = 1; i <= MeasuredRuns; i++) + { + using var session = PerformanceProfiler.Mode == 0 ? null : new PerformanceProfiler.Session(); + var sw = Stopwatch.StartNew(); + string? error = null; + try { action(); } + catch (Exception ex) { error = ex.ToString(); throw; } + finally + { + sw.Stop(); + var result = new RunResult(i, sw.Elapsed.TotalMilliseconds, error, session?.Capture()); + results.Add(result); + File.WriteAllText(Path.Combine(folder, $"run-{i:00}.json"), JsonSerializer.Serialize(result, jsonOptions)); + File.WriteAllText(Path.Combine(folder, "times.csv"), "run,elapsed_ms,success\n" + + string.Join("\n", results.Select(r => FormattableString.Invariant($"{r.Run},{r.ElapsedMilliseconds:F6},{r.Error == null}")))); + Console.WriteLine($"Прогон {i}/{MeasuredRuns}: {sw.Elapsed.TotalMilliseconds:F3} мс{(error == null ? "" : " ОШИБКА")}"); + } + } + double[] times = results.Select(x => x.ElapsedMilliseconds).ToArray(); + double mean = times.Average(); + var summary = new + { + name, + measuredRuns = MeasuredRuns, + warmupRuns, + profilerMode = PerformanceProfiler.Mode, + meanMilliseconds = mean, + medianMilliseconds = Median(times), + standardDeviationPopulationMilliseconds = Math.Sqrt(times.Select(x => (x - mean) * (x - mean)).Average()), + minMilliseconds = times.Min(), + maxMilliseconds = times.Max(), + stageMedians = results.Where(r => r.Profile != null).SelectMany(r => r.Profile!.Stages) + .GroupBy(s => s.Name).Select(g => new + { + name = g.Key, + runsPresent = g.Count(), + medianCount = Median(g.Select(s => (double)s.Count)), + medianSumMilliseconds = Median(g.Select(s => s.SumMilliseconds)) + }).OrderBy(x => x.name).ToArray() + }; + File.WriteAllText(Path.Combine(folder, "summary.json"), JsonSerializer.Serialize(summary, jsonOptions)); + var text = new StringBuilder(); + text.AppendLine($"{name}: {MeasuredRuns} измерений, прогревы исключены; профилирование={PerformanceProfiler.Mode}"); + text.AppendLine($"Среднее: {mean:F3} мс; медиана: {summary.medianMilliseconds:F3} мс; σ: {summary.standardDeviationPopulationMilliseconds:F3} мс"); + text.AppendLine($"Минимум: {times.Min():F3} мс; максимум: {times.Max():F3} мс"); + var profiles = results.Where(r => r.Profile != null).Select(r => r.Profile!).ToArray(); + if (profiles.Length > 0) + { + text.AppendLine($"Медиана процессорного времени процесса (все потоки), мс: {Median(profiles.Select(p => p.CpuMilliseconds)):F3}"); + text.AppendLine($"Медиана управляемых выделений за прогон, МиБ: {Median(profiles.Select(p => p.AllocatedBytes / (1024.0 * 1024))):F3}"); + text.AppendLine($"Медиана количества сборок GC: Gen0={Median(profiles.Select(p => (double)p.Gen0)):F0}; Gen1={Median(profiles.Select(p => (double)p.Gen1)):F0}; Gen2={Median(profiles.Select(p => (double)p.Gen2)):F0}"); + text.AppendLine("Выделения — накопленный объём, не пиковая память. Процессорное время включает все стадии и служебные потоки процесса."); + } + if (summary.stageMedians.Length > 0) + { + text.AppendLine("Стадия | медиана количества вызовов | медиана суммы времени за прогон, мс"); + foreach (var stage in summary.stageMedians) + text.AppendLine($"{stage.name} | {stage.medianCount:F0} | {stage.medianSumMilliseconds:F3}"); + text.AppendLine("Стадии перекрываются, вложенные таймеры учитывают одну работу повторно. Суммы нельзя складывать в общее время."); + text.AppendLine("Ожидания нескольких агентов суммируются и могут превышать время всего прогона."); + text.AppendLine("Таймеры стадий измеряют прошедшее время вызовов, включая вытеснение потоков; это не чистое процессорное время."); + text.AppendLine("wait_input/enqueue: длительность операции очереди вместе с ожиданием, не отдельный замер блокировки."); + text.AppendLine("Чтение включает декодирование и может обслуживаться файловым кэшем; сохранение не гарантирует физическую запись на носитель к моменту остановки таймера."); + } + File.WriteAllText(Path.Combine(folder, "summary.txt"), text.ToString()); + Console.WriteLine(text.ToString()); + Console.WriteLine($"Результаты: {folder}"); + } + + static double Median(IEnumerable values) + { + var sorted = values.OrderBy(x => x).ToArray(); + return sorted.Length % 2 == 1 ? sorted[sorted.Length / 2] : (sorted[sorted.Length / 2 - 1] + sorted[sorted.Length / 2]) / 2; + } +} \ No newline at end of file diff --git a/ImageConvolution/GpuConvolutionProcessor.cs b/ImageConvolution/GpuConvolutionProcessor.cs new file mode 100644 index 0000000..69272ac --- /dev/null +++ b/ImageConvolution/GpuConvolutionProcessor.cs @@ -0,0 +1,279 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; + +using ILGPU; +using ILGPU.Runtime; +using ILGPU.Runtime.Cuda; + +namespace ImageConvolution +{ + public class GpuConvolutionProcessor : IDisposable + { + private readonly Context context; + public readonly Accelerator accelerator; + + private readonly Action, ArrayView2D, ArrayView2D, int, int, int> convolutionKernelByte; + private MemoryBuffer2D? sourceBufferByte; + private MemoryBuffer2D? targetBufferByte; + + private MemoryBuffer2D? kernelBuffer; + private int currentWidth = 0; + private int currentHeight = 0; + private float[,]? currentKernelData; + private readonly object sync = new(); + private bool disposed; + + public static int GetDeviceCount() + { + using var context = Context.Create(builder => builder.Cuda()); + return context.Devices.Count(d => d.AcceleratorType == AcceleratorType.Cuda); + } + + public GpuConvolutionProcessor(int deviceIndex = 0) + { + using var initialization = PerformanceProfiler.Measure("gpu.initialize"); + context = Context.Create(builder => builder.Cuda().Optimize(OptimizationLevel.O2)); + try + { + var devices = context.Devices.Where(d => d.AcceleratorType == AcceleratorType.Cuda).ToArray(); + if (deviceIndex < 0 || deviceIndex >= devices.Length) + throw new ArgumentOutOfRangeException(nameof(deviceIndex), $"CUDA устройств: {devices.Length}"); + accelerator = devices[deviceIndex].CreateAccelerator(context); + PerformanceProfiler.Set("gpu.device." + deviceIndex, accelerator.Name); + convolutionKernelByte = accelerator.LoadAutoGroupedStreamKernel, ArrayView2D, ArrayView2D, int, int, int>(ConvolutionKernelByte); + } + catch + { + accelerator?.Dispose(); + context.Dispose(); + throw; + } + } + + static void ConvolutionKernelByte( + Index2D index, ArrayView2D input, ArrayView2D output, ArrayView2D kernel, int strategy, int offsetX, int offsetY) + { + int x = index.X; int y = index.Y; + int imgHeight = input.IntExtent.X; int imgWidth = input.IntExtent.Y; + int kHeight = kernel.IntExtent.X; int kWidth = kernel.IntExtent.Y; + + float sum = 0.0f; + for (int ky = 0; ky < kHeight; ky++) + { + for (int kx = 0; kx < kWidth; kx++) + { + int pixelY = y + offsetY - ky; + int pixelX = x + offsetX - kx; + float val = 0.0f; + + if (strategy == 0) + { + int clampedY = pixelY < 0 ? 0 : (pixelY >= imgHeight ? imgHeight - 1 : pixelY); + int clampedX = pixelX < 0 ? 0 : (pixelX >= imgWidth ? imgWidth - 1 : pixelX); + val = input[clampedY, clampedX]; + } + else + { + if (pixelY >= 0 && pixelY < imgHeight && pixelX >= 0 && pixelX < imgWidth) + val = input[pixelY, pixelX]; + } + sum += val * kernel[ky, kx]; + } + } + sum = sum < 0.0f ? 0.0f : (sum > 255.0f ? 255.0f : sum); + output[y, x] = (byte)(sum + 0.5f); + } + + private void EnsureBuffers(int width, int height, float[,] kernelData) + { + using var preparation = PerformanceProfiler.Measure("gpu.prepare_buffers"); + if (currentWidth != width || currentHeight != height) + { + sourceBufferByte?.Dispose(); sourceBufferByte = null; + targetBufferByte?.Dispose(); targetBufferByte = null; + currentWidth = width; + currentHeight = height; + } + var extent = new LongIndex2D(height, width); + sourceBufferByte ??= accelerator.Allocate2DDenseY(extent); + targetBufferByte ??= accelerator.Allocate2DDenseY(extent); + if (kernelBuffer == null || kernelBuffer.IntExtent.X != kernelData.GetLength(0) || kernelBuffer.IntExtent.Y != kernelData.GetLength(1)) + { + kernelBuffer?.Dispose(); + kernelBuffer = null; + currentKernelData = null; + kernelBuffer = accelerator.Allocate2DDenseY(new LongIndex2D(kernelData.GetLength(0), kernelData.GetLength(1))); + } + if (!KernelEquals(kernelData)) + { + kernelBuffer.CopyFromCPU(kernelData); + currentKernelData = (float[,])kernelData.Clone(); + } + } + + private bool KernelEquals(float[,] kernel) + { + if (currentKernelData == null || currentKernelData.GetLength(0) != kernel.GetLength(0) || currentKernelData.GetLength(1) != kernel.GetLength(1)) return false; + for (int y = 0; y < kernel.GetLength(0); y++) + for (int x = 0; x < kernel.GetLength(1); x++) + if (currentKernelData[y, x] != kernel[y, x]) return false; + return true; + } + + public byte[,] ConvolveGpu(byte[,] image, float[,] kernelData, EdgeStrategy strategy) + { + using var total = PerformanceProfiler.Measure("gpu.byte.total"); + lock (sync) + { + ObjectDisposedException.ThrowIf(disposed, this); + ConvolutionValidation.Validate(image, kernelData, strategy); + int height = image.GetLength(0), width = image.GetLength(1); + PerformanceProfiler.DescribeArray(image); + PerformanceProfiler.Set("gpu.kernel", new { height = kernelData.GetLength(0), width = kernelData.GetLength(1), edge = strategy.ToString() }); + EnsureBuffers(width, height, kernelData); + if (PerformanceProfiler.DetailedGpu) + { + using (PerformanceProfiler.Measure("gpu.byte.prepare_wait")) accelerator.Synchronize(); + using (PerformanceProfiler.Measure("gpu.byte.upload_wait")) + { + sourceBufferByte!.CopyFromCPU(image); + accelerator.Synchronize(); + } + using (PerformanceProfiler.Measure("gpu.byte.kernel_wait")) + { + convolutionKernelByte(new Index2D(width, height), sourceBufferByte!.View, targetBufferByte!.View, kernelBuffer!.View, (int)strategy, kernelData.GetLength(1) / 2, kernelData.GetLength(0) / 2); + accelerator.Synchronize(); + } + using (PerformanceProfiler.Measure("gpu.byte.download_result")) return targetBufferByte!.GetAsArray2D(); + } + sourceBufferByte!.CopyFromCPU(image); + convolutionKernelByte(new Index2D(width, height), sourceBufferByte!.View, targetBufferByte!.View, kernelBuffer!.View, (int)strategy, kernelData.GetLength(1) / 2, kernelData.GetLength(0) / 2); + return targetBufferByte.GetAsArray2D(); + } + } + + public void ConvolveGpuMathOnly_ForBenchmark(float[,] kernelData, EdgeStrategy strategy) + { + lock (sync) + { + ObjectDisposedException.ThrowIf(disposed, this); + ConvolutionValidation.ValidateKernel(kernelData, strategy); + if (sourceBufferByte == null || targetBufferByte == null || kernelBuffer == null || !KernelEquals(kernelData)) + throw new InvalidOperationException("Сначала вызовите ConvolveGpu с этим ядром"); + convolutionKernelByte(new Index2D(currentWidth, currentHeight), sourceBufferByte.View, targetBufferByte.View, kernelBuffer.View, (int)strategy, kernelData.GetLength(1) / 2, kernelData.GetLength(0) / 2); + accelerator.Synchronize(); + } + } + + public byte[,] ConvolveGpuWithTelemetry(byte[,] image, float[,] kernelData, EdgeStrategy strategy, + out double copyToGpuMs, out double kernelExecMs, out double copyFromGpuMs) + { + using var total = PerformanceProfiler.Measure("gpu.byte.total"); + lock (sync) + { + ObjectDisposedException.ThrowIf(disposed, this); + ConvolutionValidation.Validate(image, kernelData, strategy); + int height = image.GetLength(0), width = image.GetLength(1); + PerformanceProfiler.DescribeArray(image); + PerformanceProfiler.Set("gpu.kernel", new { height = kernelData.GetLength(0), width = kernelData.GetLength(1), edge = strategy.ToString() }); + EnsureBuffers(width, height, kernelData); + accelerator.Synchronize(); + var sw = Stopwatch.StartNew(); + sourceBufferByte!.CopyFromCPU(image); + accelerator.Synchronize(); + copyToGpuMs = sw.Elapsed.TotalMilliseconds; + PerformanceProfiler.AddMilliseconds("gpu.byte.upload_wait", copyToGpuMs); + sw.Restart(); + convolutionKernelByte(new Index2D(width, height), sourceBufferByte!.View, targetBufferByte!.View, kernelBuffer!.View, (int)strategy, kernelData.GetLength(1) / 2, kernelData.GetLength(0) / 2); + accelerator.Synchronize(); + kernelExecMs = sw.Elapsed.TotalMilliseconds; + PerformanceProfiler.AddMilliseconds("gpu.byte.kernel_wait", kernelExecMs); + sw.Restart(); + var result = targetBufferByte.GetAsArray2D(); + copyFromGpuMs = sw.Elapsed.TotalMilliseconds; + PerformanceProfiler.AddMilliseconds("gpu.byte.download_result", copyFromGpuMs); + return result; + } + } + + public static void ProcessDirectory(string inputDirectory, string outputDirectory, float[,] kernel, EdgeStrategy strategy, bool printStats = true) + { + if (!Directory.Exists(inputDirectory)) return; + ImageFiles.ValidateDirectories(inputDirectory, outputDirectory); + Directory.CreateDirectory(outputDirectory); + string[] files = ImageFiles.GetFiles(inputDirectory); + if (files.Length == 0) return; + PerformanceProfiler.Set("batch", new + { + inputDirectory, + files = files.Length, + parallelFiles = 1, + pixelType = "byte", + execution = "load -> upload -> kernel -> download -> save; sequential files", + timingBoundary = "outer run includes GPU initialization and disposal" + }); + + Console.WriteLine($"Найдено файлов для обработки на GPU: {files.Length}"); + using var gpuProcessor = new GpuConvolutionProcessor(); + + double totalLoadMs = 0, totalCopyToGpuMs = 0, totalKernelMs = 0, totalCopyFromGpuMs = 0, totalSaveMs = 0; + var totalStopwatch = Stopwatch.StartNew(); + var swStep = new Stopwatch(); + + for (int i = 0; i < files.Length; i++) + { + swStep.Restart(); + byte[,] imageData = PerformanceProfiler.Measure("reader.load", () => ImageIO.LoadAsGrayscaleByte(files[i])); + totalLoadMs += swStep.Elapsed.TotalMilliseconds; + + var resultData = gpuProcessor.ConvolveGpuWithTelemetry( + imageData, kernel, strategy, + out double copyToGpu, out double kernelExec, out double copyFromGpu); + + totalCopyToGpuMs += copyToGpu; totalKernelMs += kernelExec; totalCopyFromGpuMs += copyFromGpu; + + swStep.Restart(); + string fileName = Path.GetFileName(files[i]) ?? $"img_{i}.jpg"; + string savePath = Path.Combine(outputDirectory, fileName); + + using (PerformanceProfiler.Measure("writer.save")) ImageIO.SaveImageByte(resultData, savePath); + totalSaveMs += swStep.Elapsed.TotalMilliseconds; + + if (printStats) Console.Write($"\rОбработано {i + 1}/{files.Length}..."); + } + totalStopwatch.Stop(); + + if (printStats) + { + Console.WriteLine("\n\n======================================================="); + Console.WriteLine(" ДЕТАЛЬНЫЙ СУММАРНЫЙ ПРОФИЛЬ ВРЕМЕНИ "); + Console.WriteLine("======================================================="); + Console.WriteLine($"1. Загрузка диска + Декод JPEG (CPU): {totalLoadMs:F1} мс"); + Console.WriteLine($"2. Передача ОЗУ -> Видеопамять (PCIe): {totalCopyToGpuMs:F1} мс"); + Console.WriteLine($"3. Запуск + ядро GPU + синхронизация: {totalKernelMs:F1} мс"); + Console.WriteLine($"4. Передача Видеопамять -> ОЗУ (PCIe): {totalCopyFromGpuMs:F1} мс"); + Console.WriteLine($"5. Энкод JPEG + Запись диска (CPU): {totalSaveMs:F1} мс"); + Console.WriteLine("-------------------------------------------------------"); + Console.WriteLine($"Общее суммарное время по шагам: {(totalLoadMs + totalCopyToGpuMs + totalKernelMs + totalCopyFromGpuMs + totalSaveMs):F1} мс"); + Console.WriteLine($"Реальное общее время выполнения: {totalStopwatch.ElapsedMilliseconds} мс"); + Console.WriteLine("======================================================="); + } + } + + public void Dispose() + { + lock (sync) + { + if (disposed) return; + disposed = true; + sourceBufferByte?.Dispose(); targetBufferByte?.Dispose(); + kernelBuffer?.Dispose(); + accelerator.Dispose(); + context.Dispose(); + GC.SuppressFinalize(this); + } + } + } +} \ No newline at end of file diff --git a/ImageConvolution/ImageConvolution.csproj b/ImageConvolution/ImageConvolution.csproj new file mode 100644 index 0000000..649efde --- /dev/null +++ b/ImageConvolution/ImageConvolution.csproj @@ -0,0 +1,16 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + diff --git a/ImageConvolution/LibraryProcessor.cs b/ImageConvolution/LibraryProcessor.cs new file mode 100644 index 0000000..b77d758 --- /dev/null +++ b/ImageConvolution/LibraryProcessor.cs @@ -0,0 +1,153 @@ +using System; +using System.IO; + +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace ImageConvolution +{ + public class LibraryProcessor + { + // The decoder produces the L8 image consumed by BoxBlur directly, so the + // folder path does not copy every pixel through intermediate byte[,] arrays. + public static void ProcessImagesWithImageSharpDirect(string inputDirectory, string outputDirectory) + { + if (!Directory.Exists(inputDirectory)) return; + ImageFiles.ValidateDirectories(inputDirectory, outputDirectory); + Directory.CreateDirectory(outputDirectory); + string[] files = ImageFiles.GetFiles(inputDirectory); + int limit = PerformanceProfiler.Measure("setup.memory_limit", () => ImageFiles.GetInFlightLimit(files, 0, ImageFiles.EstimatedInFlightBytesPerPixel)); + var options = new ParallelOptions { MaxDegreeOfParallelism = Math.Min(Environment.ProcessorCount, limit) }; + PerformanceProfiler.Set("batch", new + { + inputDirectory, + files = files.Length, + parallelFiles = options.MaxDegreeOfParallelism, + pixelType = "L8 byte", + filter = "BoxBlur radius=1", + grayscale = "ImageSharp L8 decode", + output = "L8" + }); + Parallel.ForEach(files, options, file => + { + using var total = PerformanceProfiler.Measure("imagesharp.direct.file_total"); + using var image = PerformanceProfiler.Measure("imagesharp.direct.decode_read", () => Image.Load(file)); + using (PerformanceProfiler.Measure("imagesharp.direct.blur")) image.Mutate(x => x.BoxBlur(1)); + using (PerformanceProfiler.Measure("imagesharp.direct.encode_write")) + image.Save(Path.Combine(outputDirectory, Path.GetFileName(file))); + }); + } + + public static void ProcessImagesWithImageSharpNative(string inputDirectory, string outputDirectory) + { + if (!Directory.Exists(inputDirectory)) return; + ImageFiles.ValidateDirectories(inputDirectory, outputDirectory); + Directory.CreateDirectory(outputDirectory); + string[] files = ImageFiles.GetFiles(inputDirectory); + int limit = PerformanceProfiler.Measure("setup.memory_limit", () => ImageFiles.GetInFlightLimit(files, 0, ImageFiles.EstimatedInFlightBytesPerPixel)); + var options = new ParallelOptions { MaxDegreeOfParallelism = Math.Min(Environment.ProcessorCount, limit) }; + PerformanceProfiler.Set("batch", new + { + inputDirectory, + files = files.Length, + parallelFiles = options.MaxDegreeOfParallelism, + pixelType = "L8 byte", + filter = "BoxBlur radius=1", + grayscale = "0.299 R + 0.587 G + 0.114 B; alpha ignored", + output = "L8" + }); + Parallel.ForEach(files, options, file => + { + using var total = PerformanceProfiler.Measure("imagesharp.native.file_total"); + using var source = PerformanceProfiler.Measure("imagesharp.native.decode_read", () => Image.Load(file)); + using var image = PerformanceProfiler.Measure("imagesharp.native.output_allocate", () => new Image(source.Width, source.Height)); + using (PerformanceProfiler.Measure("imagesharp.native.grayscale")) + source.ProcessPixelRows(image, (input, output) => + { + for (int y = 0; y < source.Height; y++) + { + var from = input.GetRowSpan(y); + var to = output.GetRowSpan(y); + for (int x = 0; x < from.Length; x++) + to[x] = new L8(ImageIO.ToByte(0.299 * from[x].R + 0.587 * from[x].G + 0.114 * from[x].B)); + } + }); + using (PerformanceProfiler.Measure("imagesharp.native.blur")) image.Mutate(x => x.BoxBlur(1)); + using (PerformanceProfiler.Measure("imagesharp.native.encode_write")) + image.Save(Path.Combine(outputDirectory, Path.GetFileName(file))); + }); + } + + public static void ProcessImagesWithImageSharp(string inputDirectory, string outputDirectory) + { + if (!Directory.Exists(inputDirectory)) + { + Console.WriteLine("Ошибка: папка не найдена."); + return; + } + + if (!Directory.Exists(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + + ImageFiles.ValidateDirectories(inputDirectory, outputDirectory); + string[] files = ImageFiles.GetFiles(inputDirectory); + + var options = new ParallelOptions + { + MaxDegreeOfParallelism = Math.Min(Environment.ProcessorCount, ImageFiles.GetInFlightLimit(files, 0, ImageFiles.EstimatedInFlightBytesPerPixel)) + }; + PerformanceProfiler.Set("batch", new + { + inputDirectory, + files = files.Length, + parallelFiles = options.MaxDegreeOfParallelism, + pixelType = "byte -> L8 -> byte", + filter = "BoxBlur radius=1" + }); + Parallel.ForEach(files, options, file => + { + string savePath = Path.Combine(outputDirectory, Path.GetFileName(file)); + var source = PerformanceProfiler.Measure("reader.load", () => ImageIO.LoadAsGrayscaleByte(file)); + var result = ConvolveBox(source); + using (PerformanceProfiler.Measure("writer.save")) ImageIO.SaveImageByte(result, savePath); + }); + } + + public static byte[,] ConvolveBox(byte[,] data) + { + ConvolutionValidation.Validate(data, Kernels.BlurBoxFloat, EdgeStrategy.Extend); + using var total = PerformanceProfiler.Measure("imagesharp.total"); + PerformanceProfiler.DescribeArray(data); + using var image = PerformanceProfiler.Measure("imagesharp.allocate", () => new Image(data.GetLength(1), data.GetLength(0))); + using (PerformanceProfiler.Measure("imagesharp.pack")) + image.ProcessPixelRows(accessor => + { + for (int y = 0; y < image.Height; y++) + { + var row = accessor.GetRowSpan(y); + for (int x = 0; x < image.Width; x++) + { + row[x] = new L8(data[y, x]); + } + } + }); + using (PerformanceProfiler.Measure("imagesharp.blur")) image.Mutate(x => x.BoxBlur(1)); + return PerformanceProfiler.Measure("imagesharp.unpack", () => + { + var result = new byte[image.Height, image.Width]; + image.ProcessPixelRows(accessor => + { + for (int y = 0; y < image.Height; y++) + { + var row = accessor.GetRowSpan(y); + for (int x = 0; x < image.Width; x++) result[y, x] = row[x].PackedValue; + } + }); + return result; + }); + } + } +} \ No newline at end of file diff --git a/ImageConvolution/LocalityExperiment.cs b/ImageConvolution/LocalityExperiment.cs new file mode 100644 index 0000000..122381e --- /dev/null +++ b/ImageConvolution/LocalityExperiment.cs @@ -0,0 +1,168 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; + +using BenchmarkDotNet.Attributes; + +namespace ImageConvolution; + +public enum SequentialTraversal { Rows, Columns, BlockedColumns } + +public static class LocalityExperiment +{ + public static byte[,] Convolve(byte[,] image, float[,] kernel, EdgeStrategy edge, + SequentialTraversal traversal, int blockSize = 32) => + ConvolutionCore.Convolve(image, kernel, edge, ParallelStrategy.Sequential, traversal, blockSize); + + public static void Run(string path) + { + var image = ImageIO.LoadAsGrayscaleByte(path); + PerformanceProfiler.Mode = 0; + foreach (var traversal in Enum.GetValues()) + ExperimentRunner.Run("Locality-" + traversal, () => + { + var result = Convolve(image, Kernels.BlurBox, EdgeStrategy.Extend, traversal); + GC.KeepAlive(result); + }); + } + + public static void ParallelControl(string path, int partitions, int measuredRuns = 7) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(partitions); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(measuredRuns); + PerformanceProfiler.Mode = 0; + var image = ImageIO.LoadAsGrayscaleByte(path); + var variants = new (string Name, ParallelStrategy Strategy, int Partitions, SequentialTraversal Traversal)[] + { + ("OriginalRows", ParallelStrategy.ParallelByRows, 0, SequentialTraversal.Rows), + ("OriginalColumns", ParallelStrategy.ParallelByColumns, 0, SequentialTraversal.Rows), + ("EqualPartitionsRows", ParallelStrategy.ParallelByRows, partitions, SequentialTraversal.Rows), + ("EqualPartitionsColumns", ParallelStrategy.ParallelByColumns, partitions, SequentialTraversal.Rows), + ("EqualPartitionsTiledColumns32", ParallelStrategy.ParallelByColumns, partitions, SequentialTraversal.BlockedColumns) + }; + var expected = ConvolutionCore.Convolve(image, Kernels.BlurBox, EdgeStrategy.Extend, ParallelStrategy.Sequential); + foreach (var variant in variants) + { + var actual = ConvolutionCore.Convolve(image, Kernels.BlurBox, EdgeStrategy.Extend, + variant.Strategy, variant.Traversal, partitionCount: variant.Partitions); + for (int y = 0; y < expected.GetLength(0); y++) + for (int x = 0; x < expected.GetLength(1); x++) + if (expected[y, x] != actual[y, x]) + throw new InvalidOperationException($"Различие результата {variant.Name} в ({y}, {x})"); + GC.KeepAlive(actual); + } + var samples = variants.ToDictionary(v => v.Name, _ => new List(measuredRuns)); + var rows = new StringBuilder("run,variant,elapsed_ms\n"); + for (int run = 0; run < measuredRuns + 1; run++) + { + for (int offset = 0; offset < variants.Length; offset++) + { + var variant = variants[(offset + run) % variants.Length]; + var timer = Stopwatch.StartNew(); + var result = ConvolutionCore.Convolve(image, Kernels.BlurBox, EdgeStrategy.Extend, + variant.Strategy, variant.Traversal, partitionCount: variant.Partitions); + timer.Stop(); + GC.KeepAlive(result); + if (run == 0) continue; + double elapsed = timer.Elapsed.TotalMilliseconds; + samples[variant.Name].Add(elapsed); + rows.AppendLine(FormattableString.Invariant($"{run},{variant.Name},{elapsed:F6}")); + } + } + string folder = Path.Combine(Environment.CurrentDirectory, "MeasurementResults", + "LocalityParallelControl_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(folder); + File.WriteAllText(Path.Combine(folder, "times.csv"), rows.ToString()); + var report = new StringBuilder(); + report.AppendLine($"Один файл в памяти: {image.GetLength(1)}x{image.GetLength(0)}, byte, BoxBlur 3x3, Extend"); + report.AppendLine($"Все выходы побитно совпадают; прогрев=1; измерений={measuredRuns}; фиксированных разделов={partitions}; профиль=0"); + report.AppendLine("Разделы задают число диапазонов строк или столбцов, а не гарантированное число одновременно работающих потоков."); + foreach (var variant in variants) + { + var values = samples[variant.Name].Order().ToArray(); + double median = values.Length % 2 == 1 ? values[values.Length / 2] + : (values[values.Length / 2 - 1] + values[values.Length / 2]) / 2; + report.AppendLine(FormattableString.Invariant($"{variant.Name}: медиана={median:F3} мс; минимум={values[0]:F3}; максимум={values[^1]:F3}")); + } + report.AppendLine("Замер включает создание массива результата и свёртку; чтение и запись файла исключены."); + report.AppendLine("Это контроль порядка обхода и планирования; без аппаратных счётчиков он не измеряет промахи кэша."); + File.WriteAllText(Path.Combine(folder, "summary.txt"), report.ToString()); + Console.WriteLine(report); + Console.WriteLine($"Результаты: {folder}"); + } + + public static void HardwareWorkload(string[] args) + { + bool parallelRows = args.Length > 1 && args[1] == "ParallelRows"; + bool parallelColumns = args.Length > 1 && args[1] == "ParallelColumns"; + SequentialTraversal traversal = SequentialTraversal.Rows; + if (args.Length is < 4 or > 5 + || (!(parallelRows || parallelColumns) && (!Enum.TryParse(args[1], true, out traversal) || !Enum.IsDefined(traversal))) + || !int.TryParse(args[3], out int iterations) || iterations < 1) + throw new ArgumentException("--locality-profile Rows|Columns|ParallelRows|ParallelColumns путь_к_файлу число_повторов [отчёт.json]"); + PerformanceProfiler.Mode = 0; + var image = ImageIO.LoadAsGrayscaleByte(args[2]); + byte[,] Apply() => parallelRows || parallelColumns + ? ConvolutionCore.Convolve(image, Kernels.BlurBox, EdgeStrategy.Extend, + parallelRows ? ParallelStrategy.ParallelByRows : ParallelStrategy.ParallelByColumns) + : Convolve(image, Kernels.BlurBox, EdgeStrategy.Extend, traversal); + for (int i = 0; i < 5; i++) GC.KeepAlive(Apply()); + double checksum = 0; + DateTime startedUtc = DateTime.UtcNow; + var timer = Stopwatch.StartNew(); + for (int i = 0; i < iterations; i++) + { + var result = Apply(); + checksum += result[i % result.GetLength(0), i % result.GetLength(1)]; + } + timer.Stop(); + DateTime endedUtc = DateTime.UtcNow; + if (args.Length == 5) + File.WriteAllText(args[4], System.Text.Json.JsonSerializer.Serialize(new + { + variant = args[1], + processId = Environment.ProcessId, + startedUtc, + endedUtc, + iterations, + width = image.GetLength(1), + height = image.GetLength(0), + elapsedMilliseconds = timer.Elapsed.TotalMilliseconds, + checksum, + scope = "Convolution including result allocation; excludes decoding and five warmups" + }, new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); + Console.WriteLine(FormattableString.Invariant($"{args[1]}; iterations={iterations}; elapsed_ms={timer.Elapsed.TotalMilliseconds:F3}; checksum={checksum:R}")); + } +} + +[MemoryDiagnoser] +[IterationCount(ExperimentRunner.MeasuredRuns)] +[WarmupCount(3)] +[Outliers(Perfolizer.Mathematics.OutlierDetection.OutlierMode.DontRemove)] +[ExcludeFromCodeCoverage] +public class LocalityBenchmarks +{ + [Params(1024, 3840)] + public int Width { get; set; } + private byte[,] image = null!; + + [GlobalSetup] + public void Setup() + { + PerformanceProfiler.Mode = 0; + image = new byte[Width == 3840 ? 2160 : 1024, Width]; + var random = new Random(42); + for (int y = 0; y < image.GetLength(0); y++) + for (int x = 0; x < Width; x++) image[y, x] = (byte)random.Next(256); + } + + [Benchmark(Baseline = true)] + public byte[,] Rows() => LocalityExperiment.Convolve(image, Kernels.BlurBox, EdgeStrategy.Extend, SequentialTraversal.Rows); + + [Benchmark] + public byte[,] Columns() => LocalityExperiment.Convolve(image, Kernels.BlurBox, EdgeStrategy.Extend, SequentialTraversal.Columns); + + [Benchmark] + public byte[,] BlockedColumns() => LocalityExperiment.Convolve(image, Kernels.BlurBox, EdgeStrategy.Extend, SequentialTraversal.BlockedColumns); +} \ No newline at end of file diff --git a/ImageConvolution/PROFILING_RU.md b/ImageConvolution/PROFILING_RU.md new file mode 100644 index 0000000..74662c3 --- /dev/null +++ b/ImageConvolution/PROFILING_RU.md @@ -0,0 +1,152 @@ +# Замеры и профилирование + +**После перехода на `byte` (06.10.2026):** вход и результат всех вариантов — `byte[,]`; коэффициенты ядра и накопление суммы остаются `float`. Ниже встречается описание прежних замеров на `double`/`float`; эти численные результаты и сопоставления нужно считать историческими и измерить заново. Для текущего GPU-пути профиль 1 измеряет весь вызов, профиль 2 отдельно синхронизирует и показывает передачу, ядро и возврат. + +## Запуск + +Из папки проекта: + +```powershell +dotnet run -c Release +``` + +В режимах 1, 2, 3, 4, 5 и 7 каждый вариант выполняется один раз для прогрева, затем ровно 25 раз для измерения. Прогрев не входит в статистику. В режиме 2 это 25 запусков для внешнего параллелизма с последовательной свёрткой, ещё 25 со строковой и 25 со столбцовой. В режиме одного CPU-файла проверяются все три стратегии. Загрузка одиночного файла находится вне измерений свёртки; время папки включает её обработку целиком. Дополнительный вызов для сохранения результата одиночного файла не является измеряемым прогоном. + +Unified теперь тоже имеет 25 повторений. Каждый повтор создаёт новый UnifiedProcessor и GPU-агентов; инициализация и освобождение входят во внешний замер. Для одиночного GPU-файла процессор создаётся заранее и используется повторно: его подготовка не входит в время отдельной свёртки. + +BenchmarkDotNet: 25 измеряемых итераций для каждого метода и каждого ImageSize, отдельно 3 прогревочные итерации. Одна измеряемая итерация может содержать несколько вызовов метода — число вызовов подбирает BenchmarkDotNet. Выбросы не удаляются. Его подготовка, pilot/overhead-измерения и MemoryDiagnoser — дополнительные служебные этапы, не новые варианты свёртки. Встроенный профиль приложения в BenchmarkDotNet не включается. + +## Выбор профиля + +После выбора режима программа спрашивает: + +- `0`: общие времена и 25 результатов, без сбора стадий. +- `1` или Enter: стадии, ожидания, CPU-время процесса, выделения GC и параметры. Вызов GPU с байтовыми пикселями измеряется целиком без дополнительных ожиданий между этапами. +- `2`: то же плюс разделение GPU-вызова на передачу, ядро и получение результата. Это добавляет Synchronize и может изменить поведение и скорость. Такой профиль нужен для диагностики, его времена нельзя смешивать с режимами 0/1. + +Все текущие GPU-варианты используют байтовые вход и результат. Последовательная GPU-папка отдельно замеряет передачу, ядро и возврат; Unified использует цельный вызов GPU при профиле 0/1. + +Для новых диагностических замеров сначала используйте профиль 1. Для разбора передачи/вычислений GPU выполните отдельную серию профиля 2. Не запускайте тесты одновременно. Поддерживайте одинаковый набор входов, питание, настройки и фоновую нагрузку. Новые 25 прогонов не запускаются автоматически для всех чисел агентов: выбранная конфигурация получает свои 25 измерений; затем можно запустить программу с другой конфигурацией. + +## Где результаты + +Программа печатает абсолютный путь. Папка имеет вид: + +`MeasurementResults/дата_время_идентификатор/название-режима/` + +- `times.csv`: все 25 времён в миллисекундах, точка как десятичный разделитель. +- `run-01.json` … `run-25.json`: общий интервал, параметры и профиль каждого запуска. +- `summary.json`: среднее, медиана, минимум, максимум, стандартное отклонение с делителем n, сводка стадий. +- `summary.txt`: читаемая сводка, также выводится в консоль. + +При ошибке измеряемого запуска сохраняется JSON с ошибкой и имеющимся профилем; ошибка не скрывается. Неполную серию нельзя выдавать за 25 успешных измерений. Полная сводка создаётся только после успешных 25 запусков. Прогревы выполняются до записи измеряемых профилей. + +Пришлите папку результатов целиком или её ZIP. Одной медианы недостаточно для поиска причины разброса. Исходные изображения и папки обработанных фотографий для анализа профиля не нужны, если исходный набор не менялся. + +## Как читать стадии + +| Ключ | Содержание | +|---|---| +| reader.load | Загрузка, декодирование, серый массив | +| io.decode_read | Открытие и декодирование через ImageSharp | +| io.grayscale_allocate | Выделение массива и перевод в серый | +| reader.wait_slot | Ожидание лимита изображений в работе | +| reader.enqueue | Добавление входа в ограниченную очередь, включая ожидание места | +| cpu.wait_input / gpu.wait_input | Получение задания и ожидание входного канала | +| cpu.convolve.Sequential / ParallelByRows / ParallelByColumns | Собственная свёртка и выделение результата | +| cpu.enqueue_result / gpu.enqueue_result | Передача результата писателю, включая ожидание места | +| writer.wait_input | Ожидание результата для сохранения | +| writer.save | Вся подготовка и сохранение результата | +| io.output_allocate | Получение буфера выходного Image | +| io.encode_write | Кодирование и запись файла; без предшествующего заполнения пикселей | +| imagesharp.allocate | Получение Image | +| imagesharp.pack | Заполнение L8 из байтовой матрицы | +| imagesharp.blur | Mutate + BoxBlur | +| imagesharp.unpack | Выделение byte[,] и извлечение результата | +| imagesharp.total | Полный адаптированный библиотечный вызов | +| gpu.initialize | Создание контекста/акселератора и загрузка GPU-ядер | +| gpu.prepare_buffers | Проверка, создание/переиспользование буферов и подготовка коэффициентов | +| gpu.byte.total | Полный вызов GPU-свёртки, включая подготовку и получение результата | +| gpu.byte.prepare_wait | Ожидание после подготовки буферов в подробном профиле | +| gpu.*.upload_wait | Передача изображения на GPU с ожиданием | +| gpu.*.kernel_wait | Запуск ядра, его выполнение и ожидание | +| gpu.*.download_result | Получение массива результата в ОЗУ | +| setup.memory_limit | Чтение метаданных файлов и расчёт лимита памяти | + +`Count` — число вызовов, `SumMilliseconds` — сумма длительностей, `MeanMilliseconds` — среднее на вызов. Ожидания потребляющего перечислителя включают последний вызов, определяющий завершение канала, поэтому их количество может быть равно файлы + агенты. Сводка показывает медиану суммарной длительности стадии за один прогон; это не медиана отдельных файлов. + +Таймеры стадий измеряют elapsed time, а не чистые CPU-инструкции. Паузы GC, вытеснение ОС и ожидания ресурсов могут попасть внутрь стадии. Поле CpuMilliseconds относится ко всему процессу. + +### Не складывать все строки профиля + +Чтение, вычисление и запись конвейера перекрываются. Ожидания всех работников суммируются. Пять работников могут суммарно ждать 40 секунд за 10 секунд реального времени. Кроме того, `io.decode_read` вложен в `reader.load`, а `imagesharp.blur` вложен в `imagesharp.total`. Их повторное сложение дважды учтёт одну работу. + +Суммарное ожидание / (число агентов данной роли × время прогона) даёт ориентировочную долю ожидания, с оговоркой о запуске и завершении конвейера. Это не измерение загрузки физических ядер. + +## Как искать ограничение + +- Читатель занят почти весь проход, cpu/gpu.wait_input велики: вычислители ждут подготовку входов. +- writer.save близок ко времени работы одного писателя, enqueue_result велик: выходная очередь заполняется, ограничивает сохранение. +- reader.wait_slot велик: закончились разрешения на изображения в работе. Это может быть следствием медленного писателя, а не нехватки физической ОЗУ. +- cpu.convolve велик, ожидания малы: вычислительная стадия может ограничивать поток. +- imagesharp.pack/unpack велики: заметную часть библиотечного пути занимают адаптеры представлений. +- gpu.initialize велик: подготовка устройства существенна для выбранных границ замера. + +Это признаки для совместного анализа, а не автоматическое доказательство одной причины. Физическое чтение диска отдельно от декодирования не измеряется. Также не измеряются аппаратные cache misses, TLB misses, температура и частоты. + +## Строки и столбцы + +В Metadata.layout сохраняются размер элемента и фактические смещения между соседними элементами прямоугольного массива, полученные через Unsafe.ByteOffset: + +- nextColumnBytes: переход от [0,0] к [0,1], то есть вправо по строке; +- nextRowBytes: переход от [0,0] к [1,0], то есть вниз по столбцу. + +Для текущего byte-массива шириной 3840 это 1 и 3840 байт. Для единственного столбца/строки соответствующий сосед отсутствует и значение равно null. Измерение доказывает шаг расположения элементов, но не число промахов кэша. Таймеры показывают, сколько заняли соответствующие стратегии. + +## Почему Unified GPU быстрее последовательной GPU-папки + +В последовательной GPU-папке выполняется цепочка одного файла целиком, затем следующего. В Unified читатели, GPU-агенты и писатели могут одновременно заниматься разными изображениями. Поэтому задержки стадий перекрываются и общая скорость не равна сумме их времён. + +Старые 31,332 с и 11,419 с относятся к прежним реализациям и не являются результатами версии с единым байтовым представлением. По умолчанию сейчас один читатель и один писатель, их числа можно указать при запуске пункта 7. Они сохраняются в имени серии и профиле. 0 CPU означает отсутствие CPU-свёртки, но чтение, кодирование и управление GPU всё равно работают на CPU. + +Для сопоставления старых конфигураций выберите в Unified: CPU=0, GPU=2, чтение=2, запись=2. Для текущего варианта 1/1 просто нажмите Enter на вопросах чтения и записи. Это разные серии, их результаты нельзя объединять. Два GPU-агента на одной RTX не означают две физические видеокарты. + +## Проведённая проверка изменений + +Это описание проверки прежней версии. После перевода пикселей на `byte` сборку и корректность следует проверять отдельно от длительных измерений производительности. + +## ImageSharp без промежуточных массивов (пункт 9, 23.09) + +Пункт 4 использует обёртку: `byte[,]` → `Image` → `BoxBlur(1)` → `byte[,]`. Пункт 9 запускает прямой ImageSharp-путь: `Image.Load` → перевод в `Image` → `BoxBlur(1)` → сохранение. В обоих случаях рабочее изображение фильтра — байтовое. Между обёрткой и прямым путём возможна разница округления яркости при чтении JPEG; её проверяют по пикселям. + +Таймеры imagesharp.native: decode_read (загрузка и декодирование), output_allocate (выделение изображения L8), grayscale (заполнение яркостей), blur (библиотечный фильтр), encode_write (кодирование и запись), file_total (весь файл, включает остальные стадии). setup.memory_limit — предварительная оценка лимита памяти. Для ограничения числа одновременных файлов используется оценка 8 байт/пиксель с учётом временного декодированного изображения и буферов; это не точный пик потребления. Реальное parallelFiles записывается в JSON профиля. + +Запуск: dotnet run -c Release --project ImageConvolution/ImageConvolution.csproj. Пункт 4 или 9, профиль 0 для полного времени либо 1 для диагностики, папка C:\Test4K. Каждый запуск: отдельный прогрев и 25 измерений. Выход пункта 9: C:\ImageSharp_Native_Output при входе C:\Test4K. Сравнивать оба варианта при одинаковом профиле и наборе входов; желательно чередовать серии. Не запускать серии одновременно. Детальный профиль GPU (2) для этого сравнения не нужен. + +Профиль фиксирует прошедшее время вызовов, включая вытеснение потока, а не CPU cycles. Очередные операции включают как ожидание, так и собственную стоимость. Диск не изолирован от кодеков и файлового кэша; завершение Save не означает принудительный сброс на физический носитель. GPU kernel_wait включает запуск и ожидание завершения; режим 2 добавляет синхронизации. Эти ограничения не делают показания неправильными, но задают пределы их интерпретации. + +В summary.txt дополнительно выводятся уже собиравшиеся CPU-время процесса, управляемые выделения и количества GC. CPU-время включает все потоки/стадии; выделения не равны пику занятой памяти. + +Для столбцов уже есть cpu.convolve.ParallelByColumns и layout со смещениями. Таймер на каждый пиксель не добавлять: он исказит горячий цикл и не измерит промахи кэша. Контроль: строки/столбцы на одном массиве без файлов (пункт 1 или BenchmarkDotNet), затем аппаратные счётчики cache misses/TLB/memory stalls через Intel VTune при доступности на данном CPU и Windows. Для причинного сравнения дополнительно полезен последовательный обход по строкам/столбцам без влияния планировщика. Пока такого контроля и аппаратных данных нет, локальность — обоснованное объяснение, а не измеренная доля замедления. + +Следующие приоритеты: изолированный ImageSharp benchmark без обёртки с восстановлением исходных пикселей вне измерения; CPU float по строкам/столбцам; сравнение Unified с фиксированными GPU/R/W при изменении только CPU. Блочная свёртка и разделимый BoxBlur — отдельные оптимизированные методы после базовых измерений; не подменять ими существующие варианты и не смешивать общую свёртку произвольного ядра с алгоритмом только для разделимых ядер. + +## Контроль локальности (пункты 10 и 11) + +Пункт 10: один выбранный файл загружается один раз, вне измерений. Три последовательных варианта Rows, Columns, BlockedColumns вызывают общий ConvolutionCore; математика пикселя, порядок коэффициентов, тип double, BoxBlur 3x3 и Extend одинаковы. Варианты не используют Parallel.For. BlockedColumns проходит плитки 32x32, внутри каждой — по столбцам. Размер 32 является одной проверяемой настройкой, не доказанным оптимумом. Каждый вариант: один прогрев и 25 измерений. Время включает создание результата, весов и смещений, а также возможную GC; не является исключительно временем арифметики. Профиль стадий отключён. Старые режимы сохраняют прежний порядок обхода. + +Пункт 11: отдельный BenchmarkDotNet-класс LocalityBenchmarks без создания GPU. Два размера: 1024x1024 и 3840x2160; одинаковые псевдослучайные данные Random(42), 3 прогревочные и 25 измерительных итераций. Вход создаётся в GlobalSetup. Три метода возвращают результат, MemoryDiagnoser фиксирует выделения. Вычисляющий поток один, но процесс .NET может иметь служебные потоки. Не запускайте пункты 10 и 11 одновременно. Для первого ответа достаточно пункта 10; BDN полезен для более воспроизводимой проверки. + +Аппаратный сбор подготовлен в run-locality-vtune.ps1. VTune в PATH и стандартных каталогах Intel при проверке не обнаружен; сбор не запускался. После установки либо указания существующего vtune.exe: + +```powershell +.\run-locality-vtune.ps1 -ImagePath 'C:\Test4K\имя_реального_файла.jpg' -VtunePath 'полный_путь\vtune.exe' +``` + +Скрипт собирает memory-access последовательно для трёх вариантов и сохраняет журналы, сводки и отчёты по функциям. Для дополнительного анализа доступен -Analysis uarch-exploration. Возможность сбора cache/TLB/stall событий зависит от CPU, версии VTune, драйвера и прав. Скрипт проверяет коды завершения и не выдаёт отсутствие метрики за ноль. Команды отчётов подготовлены по официальному CLI, но на этой машине не проверены, поскольку VTune отсутствует. + +Важно: Stopwatch внутри --locality-profile исключает загрузку и три прогрева. VTune собирает весь процесс, включая запуск, загрузку, JIT, прогрев и GC. В аппаратном отчёте нужно анализировать функцию ConvolutionCore и её Line, а не приписывать все события процесса свёртке. Если версия VTune не разрешает .NET-символы, сначала настройте их разрешение; без этого вывод по конкретному циклу преждевременен. 100 повторов в аппаратной нагрузке — увеличение длительности сбора, а не 100 независимых измерений. У всех режимов одинаковое число обработанных пикселей; сравнивайте события на пиксель/вызов, а не только сырые суммы. Проценты stalls нельзя напрямую превращать в процент причины замедления. На гибридном i5-12450H также учитывайте миграцию между P/E-ядрами и частоты. + +Интерпретация: Columns медленнее Rows без параллелизма — планировщик вложенных циклов не необходим для возникновения разницы. Рост промахов/задержек памяти в той же вычисляющей функции усиливает объяснение через локальность. Ускорение BlockedColumns одновременно с уменьшением аппаратных задержек — дополнительное подтверждение, но не гарантированный результат. Плитки добавляют накладные расходы циклов; изменение времени само по себе не доказывает только кэш. До реального сбора не писать, что аппаратный профиль подтвердил причину. + +Источники: https://www.intel.com/content/www/us/en/docs/vtune-profiler/user-guide/2024-0/running-command-line-analysis.html и https://www.intel.com/content/www/us/en/docs/vtune-profiler/user-guide/2025-1/report.html diff --git a/ImageConvolution/PerformanceProfiler.cs b/ImageConvolution/PerformanceProfiler.cs new file mode 100644 index 0000000..af0f4fd --- /dev/null +++ b/ImageConvolution/PerformanceProfiler.cs @@ -0,0 +1,136 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading.Channels; + +namespace ImageConvolution; + +public static class PerformanceProfiler +{ + static readonly AsyncLocal current = new(); + public static int Mode { get; set; } = 1; + public static bool IsActive => current.Value != null; + public static bool DetailedGpu => current.Value != null && Mode == 2; + + public sealed record Stage(string Name, long Count, double SumMilliseconds, double MeanMilliseconds); + public sealed record Snapshot(double CpuMilliseconds, long AllocatedBytes, int Gen0, int Gen1, int Gen2, + Dictionary Metadata, Stage[] Stages); + + public sealed class Session : IDisposable + { + internal sealed class Counter { public long Ticks; public long Count; } + readonly ConcurrentDictionary counters = new(); + readonly ConcurrentDictionary metadata = new(); + readonly Session? previous; + readonly long allocated = GC.GetTotalAllocatedBytes(false); + readonly int g0 = GC.CollectionCount(0), g1 = GC.CollectionCount(1), g2 = GC.CollectionCount(2); + readonly Process process = Process.GetCurrentProcess(); + readonly TimeSpan cpuStart; + + public Session() + { + cpuStart = process.TotalProcessorTime; + previous = current.Value; + current.Value = this; + metadata["profilingMode"] = Mode; + metadata["logicalProcessors"] = Environment.ProcessorCount; + metadata["runtime"] = RuntimeInformation.FrameworkDescription; + metadata["os"] = RuntimeInformation.OSDescription; + } + + internal void Add(string name, long ticks) + { + var counter = counters.GetOrAdd(name, _ => new()); + Interlocked.Add(ref counter.Ticks, ticks); + Interlocked.Increment(ref counter.Count); + } + + internal void Set(string key, object value) => metadata[key] = value; + + public Snapshot Capture() => new((process.TotalProcessorTime - cpuStart).TotalMilliseconds, + GC.GetTotalAllocatedBytes(false) - allocated, GC.CollectionCount(0) - g0, + GC.CollectionCount(1) - g1, GC.CollectionCount(2) - g2, metadata.ToDictionary(x => x.Key, x => x.Value), + counters.OrderBy(x => x.Key).Select(x => new Stage(x.Key, x.Value.Count, + x.Value.Ticks * 1000.0 / Stopwatch.Frequency, + x.Value.Count == 0 ? 0 : x.Value.Ticks * 1000.0 / Stopwatch.Frequency / x.Value.Count)).ToArray()); + + public void Dispose() + { + current.Value = previous; + process.Dispose(); + } + } + + public readonly struct Scope : IDisposable + { + readonly Session? session; + readonly string name; + readonly long start; + internal Scope(string name) + { + session = current.Value; + this.name = name; + start = session == null ? 0 : Stopwatch.GetTimestamp(); + } + public void Dispose() + { + if (session != null) session.Add(name, Stopwatch.GetTimestamp() - start); + } + } + + public static Scope Measure(string name) => new(name); + public static T Measure(string name, Func action) + { + using var scope = Measure(name); + return action(); + } + public static void Measure(string name, Action action) + { + using var scope = Measure(name); + action(); + } + public static void Set(string name, object value) => current.Value?.Set(name, value); + public static void AddMilliseconds(string name, double milliseconds) => current.Value?.Add(name, + (long)(milliseconds * Stopwatch.Frequency / 1000.0)); + + public static void DescribeArray(T[,] data) where T : unmanaged + { + if (current.Value == null || data.Length == 0) return; + int height = data.GetLength(0), width = data.GetLength(1); + Set($"layout.{typeof(T).Name}.{width}x{height}", new + { + width, + height, + elementBytes = Unsafe.SizeOf(), + nextColumnBytes = width > 1 ? (long?)Unsafe.ByteOffset(ref data[0, 0], ref data[0, 1]) : null, + nextRowBytes = height > 1 ? (long?)Unsafe.ByteOffset(ref data[0, 0], ref data[1, 0]) : null, + indexing = "[y,x]; nextColumn = horizontal; nextRow = vertical" + }); + } + + public static IEnumerable Consume(BlockingCollection queue, string stage, CancellationToken token) + { + using var iterator = queue.GetConsumingEnumerable(token).GetEnumerator(); + while (true) + { + bool available; + using (Measure(stage)) available = iterator.MoveNext(); + if (!available) yield break; + yield return iterator.Current; + } + } + + public static async IAsyncEnumerable ConsumeAsync(ChannelReader reader, string stage, + [EnumeratorCancellation] CancellationToken token) + { + await using var iterator = reader.ReadAllAsync(token).GetAsyncEnumerator(token); + while (true) + { + bool available; + using (Measure(stage)) available = await iterator.MoveNextAsync(); + if (!available) yield break; + yield return iterator.Current; + } + } +} \ No newline at end of file diff --git a/ImageConvolution/Program.cs b/ImageConvolution/Program.cs new file mode 100644 index 0000000..7aa6856 --- /dev/null +++ b/ImageConvolution/Program.cs @@ -0,0 +1,305 @@ +using System; +using System.Data; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Threading.Tasks; + +using BenchmarkDotNet.Running; + +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace ImageConvolution +{ + public enum EdgeStrategy { Extend, ZeroPadding } + + [ExcludeFromCodeCoverage] + class Program + { + static string? ReadPath() => Console.ReadLine()?.Trim('"', ' ', '\''); + + static int ReadCount(string prompt, int fallback, bool allowZero = false) + { + Console.Write(prompt); + string? value = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(value)) return fallback; + if (!int.TryParse(value, out int count) || count < (allowZero ? 0 : 1)) + throw new ArgumentException("Некорректное количество агентов"); + return count; + } + + static string OutputFolder(string input, string name) + { + string? root = Environment.GetEnvironmentVariable("IMAGECONV_OUTPUT_ROOT"); + return Path.Combine(string.IsNullOrWhiteSpace(root) + ? Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(Path.GetFullPath(input))) ?? Environment.CurrentDirectory + : Path.GetFullPath(root), name); + } + + static void Main(string[] args) + { + if (args.Length > 0 && args[0] == "--byte-layout") + { + ByteLayoutExperiment.Run(args); + return; + } + if (args.Length > 0 && args[0] == "--locality-parallel-control") + { + if (args.Length < 2 || args.Length > 4 || !File.Exists(args[1])) + throw new ArgumentException("--locality-parallel-control путь_к_файлу [число_разделов] [число_измерений]"); + int partitions = args.Length > 2 ? int.Parse(args[2]) : Environment.ProcessorCount; + int runs = args.Length > 3 ? int.Parse(args[3]) : 7; + LocalityExperiment.ParallelControl(args[1], partitions, runs); + return; + } + if (args.Length > 0 && args[0] == "--locality-profile") + { + LocalityExperiment.HardwareWorkload(args); + return; + } +#if DEBUG + Console.WriteLine("Сборка DEBUG. Для замеров используйте: dotnet run -c Release"); +#endif + Console.WriteLine("=== Программа свёртки изображений ==="); + Console.WriteLine("1. Обработать один файл"); + Console.WriteLine("2. Обработать набор файлов"); + Console.WriteLine("3. Обработать набор файлов агентами"); + Console.WriteLine("4. Сравнить с библиотекой ImageSharp"); + Console.WriteLine("5. Обработать файл или папку на GPU"); + Console.WriteLine("6. Создать 4K тестовые изображения"); + Console.WriteLine("7. Unified обработка (CPU + GPU)"); + Console.WriteLine("8. Запустить BenchmarkDotNet"); + Console.WriteLine("9. ImageSharp напрямую: без промежуточных массивов яркости"); + Console.WriteLine("10. Контроль обхода: строки, столбцы, блоки (один файл в памяти)"); + Console.WriteLine("11. BenchmarkDotNet: контроль обхода без GPU"); + Console.WriteLine("12. CPU-папка: файлы и свёртка последовательно (аналог GPU-папки)"); + Console.WriteLine("13. ImageSharp напрямую: загрузка в L8 без промежуточных массивов"); + string? choice = Console.ReadLine(); + if (choice is "1" or "2" or "3" or "4" or "5" or "7" or "9" or "12" or "13") + { + Console.WriteLine("Профиль: 0 — только время; 1 — стадии и ожидания (Enter); 2 — также подробные GPU-этапы."); + string? profile = Console.ReadLine(); + PerformanceProfiler.Mode = string.IsNullOrWhiteSpace(profile) ? 1 : int.TryParse(profile, out int mode) && mode >= 0 && mode <= 2 ? mode : throw new ArgumentException("Неверный режим профиля"); + Console.WriteLine($"Каждый вариант: {ExperimentRunner.MeasuredRuns} измерений после отдельного прогрева."); + Console.WriteLine($"Отчёты: {ExperimentRunner.ResultsDirectory}"); + if (PerformanceProfiler.Mode == 2) + Console.WriteLine("Подробный GPU-профиль добавляет синхронизацию и может менять время. Сравнивайте одинаковые режимы профилирования."); + } + if (choice == "10") + { + Console.Write("Введите путь к изображению: "); + string? path = ReadPath(); + if (!File.Exists(path)) return; + Console.WriteLine("byte; BoxBlur 3x3; Extend; один вычисляющий поток; блок 32x32; выделение результата включено; файлы вне замера."); + LocalityExperiment.Run(path); + } + else if (choice == "11") + { + PerformanceProfiler.Mode = 0; + BenchmarkRunner.Run(); + } + else if (choice == "1") + { + Console.Write("Введите путь к изображению: "); + string? path = ReadPath(); + if (!File.Exists(path)) return; + byte[,] image = ImageIO.LoadAsGrayscaleByte(path); + foreach (var strategy in new[] { ParallelStrategy.Sequential, ParallelStrategy.ParallelByRows, ParallelStrategy.ParallelByColumns }) + ExperimentRunner.Run("Single-" + strategy, () => + { + PerformanceProfiler.Set("input", Path.GetFullPath(path)); + PerformanceProfiler.Set("timingBoundary", "Image already in RAM; convolution and result allocation only"); + _ = ConvolutionCore.Convolve(image, Kernels.BlurBox, EdgeStrategy.Extend, strategy); + }); + byte[,] result = ConvolutionProcessor.Convolve(image, Kernels.BlurBox); + string stem = Path.Combine(Path.GetDirectoryName(path)!, Path.GetFileNameWithoutExtension(path) + "_filtered"); + int index = 1; + string output; + do { output = stem + "_" + index++ + Path.GetExtension(path); } while (File.Exists(output)); + ImageIO.SaveImageByte(result, output); + Console.WriteLine($"Сохранено: {output}"); + } + else if (choice is "2" or "3" or "4" or "7" or "9" or "12" or "13") + { + Console.Write("Введите путь к папке: "); + string? input = ReadPath(); + if (string.IsNullOrWhiteSpace(input) || !Directory.Exists(input)) return; + if (choice == "12") + ExperimentRunner.Run("Batch-CPU-Sequential", () => BatchProcessor.ProcessImagesSequential(input, + OutputFolder(input, "CPU_Sequential_Output"))); + else if (choice == "2") + foreach (var strategy in new[] { ParallelStrategy.Sequential, ParallelStrategy.ParallelByRows, ParallelStrategy.ParallelByColumns }) + ExperimentRunner.Run("Batch-" + strategy, () => BatchProcessor.ProcessImagesNaiveParallel(input, OutputFolder(input, "Processed_" + strategy), strategy)); + else if (choice == "3") + { + int workers = ReadCount($"Вычислительных агентов (Enter для {Environment.ProcessorCount}): ", Environment.ProcessorCount); + int readers = ReadCount("Агентов чтения (Enter для 1): ", 1); + int writers = ReadCount("Агентов записи (Enter для 1): ", 1); + ExperimentRunner.Run($"Agents-{workers}-R{readers}-W{writers}", () => AgentProcessor.ProcessImagesWithAgents(input, + OutputFolder(input, "Agent_Processed_Output"), workers, readerCount: readers, writerCount: writers)); + } + else if (choice == "4") + ExperimentRunner.Run("ImageSharp", () => LibraryProcessor.ProcessImagesWithImageSharp(input, OutputFolder(input, "ImageSharp_Output"))); + else if (choice == "9") + ExperimentRunner.Run("ImageSharp-Native", () => LibraryProcessor.ProcessImagesWithImageSharpNative(input, OutputFolder(input, "ImageSharp_Native_Output"))); + else if (choice == "13") + ExperimentRunner.Run("ImageSharp-Direct-L8", () => LibraryProcessor.ProcessImagesWithImageSharpDirect(input, OutputFolder(input, "ImageSharp_Direct_L8_Output"))); + else + { + int cpu = ReadCount("CPU агентов (Enter для auto; 0 — без CPU-свёртки): ", Math.Max(1, Environment.ProcessorCount / 2), true); + int gpu = ReadCount("GPU агентов (Enter для 1; 0 — без GPU): ", 1, true); + int readers = ReadCount("Агентов чтения (Enter для 1): ", 1); + int writers = ReadCount("Агентов записи (Enter для 1): ", 1); + var config = new UnifiedProcessorConfig + { + CpuWorkers = cpu, + GpuWorkers = gpu, + ReaderThreads = readers, + WriterThreads = writers, + Kernel = Kernels.BlurBoxFloat, + Strategy = EdgeStrategy.Extend + }; + ExperimentRunner.Run($"Unified-CPU{cpu}-GPU{gpu}-R{readers}-W{writers}", () => + { + using var processor = new UnifiedProcessor(config); + processor.ProcessDirectory(input, OutputFolder(input, "Unified_Output")); + }); + } + } + else if (choice == "5") + { + Console.Write("Введите путь к файлу или папке: "); + string? path = ReadPath(); + if (string.IsNullOrWhiteSpace(path)) return; + if (File.Exists(path)) + { + byte[,] image = ImageIO.LoadAsGrayscaleByte(path); + using var gpu = new GpuConvolutionProcessor(); + ExperimentRunner.Run("Single-GPU-byte", () => + { + PerformanceProfiler.Set("input", Path.GetFullPath(path)); + PerformanceProfiler.Set("timingBoundary", "Image in RAM; GPU already initialized; transfers and result included"); + _ = gpu.ConvolveGpu(image, Kernels.BlurBoxFloat, EdgeStrategy.Extend); + }); + string output = Path.Combine(Path.GetDirectoryName(path)!, Path.GetFileNameWithoutExtension(path) + "_gpu_processed" + Path.GetExtension(path)); + ImageIO.SaveImageByte(gpu.ConvolveGpu(image, Kernels.BlurBoxFloat, EdgeStrategy.Extend), output); + Console.WriteLine($"Сохранено: {output}"); + } + else if (Directory.Exists(path)) + ExperimentRunner.Run("Batch-GPU-byte", () => GpuConvolutionProcessor.ProcessDirectory(path, + OutputFolder(path, "GPU_Batch_Output"), Kernels.BlurBoxFloat, EdgeStrategy.Extend, false)); + } + else if (choice == "6") + { + string output = Path.Combine(Environment.CurrentDirectory, "Test4K"); + Directory.CreateDirectory(output); + for (int i = 0; i < 100; i++) + { + var image = new byte[2160, 3840]; + var random = new Random(i); + for (int y = 0; y < 2160; y++) + for (int x = 0; x < 3840; x++) image[y, x] = (byte)random.Next(256); + ImageIO.SaveImageByte(image, Path.Combine(output, $"test_{i:000}.jpg")); + Console.WriteLine($"Создано {i + 1}/100"); + } + Console.WriteLine($"Готово: {output}"); + } + else if (choice == "8") + { + PerformanceProfiler.Mode = 0; + Console.WriteLine("BenchmarkDotNet: 25 измеряемых итераций на каждый метод и размер; прогрев отдельно. Итерация может содержать несколько вызовов."); + BenchmarkRunner.Run(); + } + else Console.WriteLine("Неверный выбор."); + } + } + + + public class ImageIO + { + public static byte ToByte(double value) => !(value > 0) ? (byte)0 : value >= 255 ? (byte)255 : (byte)(value + 0.5); + + + public static byte[,] LoadAsGrayscaleByte(string path) + { + using Image image = PerformanceProfiler.Measure("io.decode_read", () => Image.Load(path)); + using var grayscale = PerformanceProfiler.Measure("io.grayscale_allocate"); + byte[,] result = new byte[image.Height, image.Width]; + image.ProcessPixelRows(accessor => + { + for (int y = 0; y < image.Height; y++) + { + var row = accessor.GetRowSpan(y); + for (int x = 0; x < image.Width; x++) + result[y, x] = ToByte(0.299 * row[x].R + 0.587 * row[x].G + 0.114 * row[x].B); + } + }); + return result; + } + + public static void SaveImageByte(byte[,] data, string path) + { + int height = data.GetLength(0); + int width = data.GetLength(1); + + using Image image = PerformanceProfiler.Measure("io.output_allocate", () => new Image(width, height)); + + image.ProcessPixelRows(accessor => + { + for (int y = 0; y < height; y++) + { + Span pixelRow = accessor.GetRowSpan(y); + for (int x = 0; x < width; x++) + { + pixelRow[x] = new L8(data[y, x]); + } + } + }); + + using (PerformanceProfiler.Measure("io.encode_write")) image.Save(path); + } + + } + + public class Kernels + { + // Пиксели хранятся как byte; дробные коэффициенты нужны для усреднения. + public static readonly float[,] Sharpen = + { + { 0, -1, 0 }, + { -1, 5, -1 }, + { 0, -1, 0 } + }; + public static readonly float[,] Laplacian = + { + { -1, -1, -1 }, + { -1, 8, -1 }, + { -1, -1, -1 } + }; + public static readonly float[,] BlurBox = + { + { 1f/9f, 1f/9f, 1f/9f }, + { 1f/9f, 1f/9f, 1f/9f }, + { 1f/9f, 1f/9f, 1f/9f } + }; + public static float[,] SharpenFloat => Sharpen; + public static float[,] LaplacianFloat => Laplacian; + public static float[,] BlurBoxFloat => BlurBox; + } + + public class ConvolutionProcessor + { + public static byte[,] Convolve(byte[,] image, float[,] kernel, EdgeStrategy strategy = EdgeStrategy.Extend) => + ConvolutionCore.Convolve(image, kernel, strategy, ParallelStrategy.Sequential); + } + + public class ParallelConvolutionProcessor + { + public static byte[,] ConvolveParallel(byte[,] image, float[,] kernel, EdgeStrategy strategy = EdgeStrategy.Extend) => + ConvolutionCore.Convolve(image, kernel, strategy, ParallelStrategy.ParallelByRows); + + public static byte[,] ConvolveParallelByColumns(byte[,] image, float[,] kernel, EdgeStrategy strategy = EdgeStrategy.Extend) => + ConvolutionCore.Convolve(image, kernel, strategy, ParallelStrategy.ParallelByColumns); + } +} \ No newline at end of file diff --git a/ImageConvolution/UnifiedProcessor.cs b/ImageConvolution/UnifiedProcessor.cs new file mode 100644 index 0000000..3413994 --- /dev/null +++ b/ImageConvolution/UnifiedProcessor.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace ImageConvolution +{ + public enum ProcessorType { CPU, GPU } + + public class ProcessingTask + { + public required string InputPath { get; set; } + public required string OutputPath { get; set; } + public required byte[,] ImageData { get; set; } + } + + public class ProcessingResult + { + public required string OutputPath { get; set; } + public required byte[,] ResultData { get; set; } + public ProcessorType ProcessedBy { get; set; } + } + + public class UnifiedProcessorConfig + { + public int CpuWorkers { get; set; } = Math.Max(1, Environment.ProcessorCount / 2); + public int GpuWorkers { get; set; } = 1; + public int ReaderThreads { get; set; } = 1; + public int WriterThreads { get; set; } = 1; + public int QueueCapacity { get; set; } = 4; + public long MemoryBudgetBytes { get; set; } = 0; + public ParallelStrategy CpuStrategy { get; set; } = ParallelStrategy.Sequential; + public float[,] Kernel { get; set; } = Kernels.BlurBoxFloat; + public EdgeStrategy Strategy { get; set; } = EdgeStrategy.Extend; + } + + public class UnifiedProcessor : IDisposable + { + private readonly UnifiedProcessorConfig config; + private readonly object sync = new(); + private bool disposed; + + public UnifiedProcessor(UnifiedProcessorConfig config) + { + ArgumentNullException.ThrowIfNull(config); + if (config.CpuWorkers < 0 || config.GpuWorkers < 0 || (config.CpuWorkers == 0 && config.GpuWorkers == 0)) + throw new ArgumentException("Нужен хотя бы один вычислитель", nameof(config)); + if (config.ReaderThreads <= 0 || config.WriterThreads <= 0 || config.QueueCapacity <= 0 || config.MemoryBudgetBytes < 0) + throw new ArgumentException("Нужны положительные количества читателей, писателей и мест в очереди; бюджет памяти: 0 (авто) или положительное число", nameof(config)); + ConvolutionValidation.ValidateKernel(config.Kernel, config.Strategy); + if (!Enum.IsDefined(config.CpuStrategy)) throw new ArgumentOutOfRangeException(nameof(config)); + this.config = new UnifiedProcessorConfig + { + CpuWorkers = config.CpuWorkers, + GpuWorkers = config.GpuWorkers, + ReaderThreads = config.ReaderThreads, + WriterThreads = config.WriterThreads, + QueueCapacity = config.QueueCapacity, + MemoryBudgetBytes = config.MemoryBudgetBytes, + Kernel = (float[,])config.Kernel.Clone(), + Strategy = config.Strategy, + CpuStrategy = config.CpuStrategy + }; + } + + public void ProcessDirectory(string inputDirectory, string outputDirectory) + { + lock (sync) + { + ObjectDisposedException.ThrowIf(disposed, this); + ProcessDirectoryAsync(inputDirectory, outputDirectory).GetAwaiter().GetResult(); + } + } + + private async Task ProcessDirectoryAsync(string inputDirectory, string outputDirectory) + { + if (!Directory.Exists(inputDirectory)) return; + ImageFiles.ValidateDirectories(inputDirectory, outputDirectory); + Directory.CreateDirectory(outputDirectory); + var timer = Stopwatch.StartNew(); + var files = ImageFiles.GetFiles(inputDirectory); + if (files.Length == 0) return; + int deviceCount = config.GpuWorkers > 0 ? GpuConvolutionProcessor.GetDeviceCount() : 0; + if (config.GpuWorkers > 0 && deviceCount == 0) + throw new InvalidOperationException("Запрошены GPU агенты, но CUDA устройства не найдены"); + int limit = PerformanceProfiler.Measure("setup.memory_limit", () => ImageFiles.GetInFlightLimit(files, config.MemoryBudgetBytes, ImageFiles.EstimatedInFlightBytesPerPixel)); + PerformanceProfiler.Set("pipeline", new + { + inputDirectory, + files = files.Length, + cpuWorkers = config.CpuWorkers, + gpuWorkers = config.GpuWorkers, + readers = config.ReaderThreads, + writers = config.WriterThreads, + queueCapacity = config.QueueCapacity, + inFlightLimit = limit, + pixelType = "byte", + deviceCount, + cpuStrategy = config.CpuStrategy.ToString(), + config.MemoryBudgetBytes + }); + using var slots = new SemaphoreSlim(limit); + using var cancellation = new CancellationTokenSource(); + var token = cancellation.Token; + var inputChannel = Channel.CreateBounded(config.QueueCapacity); + var outputChannel = Channel.CreateBounded(config.QueueCapacity); + var filesQueue = new ConcurrentQueue(files); + Exception? failure = null; + int processedCount = 0, cpuCount = 0, gpuCount = 0; + Task Start(Func action) => Task.Run(async () => + { + try { await action(); } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + catch (Exception ex) + { + Interlocked.CompareExchange(ref failure, ex, null); + cancellation.Cancel(); + } + }); + async Task Complete(Task[] tasks, Action complete) + { + try { await Task.WhenAll(tasks); } + finally { complete(); } + } + var readers = Enumerable.Range(0, config.ReaderThreads).Select(_ => Start(async () => + { + while (filesQueue.TryDequeue(out var file)) + { + using (PerformanceProfiler.Measure("reader.wait_slot")) await slots.WaitAsync(token); + var data = PerformanceProfiler.Measure("reader.load", () => ImageIO.LoadAsGrayscaleByte(file)); + using (PerformanceProfiler.Measure("reader.enqueue")) + await inputChannel.Writer.WriteAsync(new ProcessingTask + { + InputPath = file, + OutputPath = Path.Combine(outputDirectory, Path.GetFileName(file)), + ImageData = data + }, token); + data = null!; + } + })).ToArray(); + var readerCompletion = Complete(readers, () => inputChannel.Writer.TryComplete()); + var workers = new List(); + for (int i = 0; i < config.GpuWorkers; i++) + { + int deviceIndex = i % deviceCount; + workers.Add(Start(async () => + { + using var gpu = new GpuConvolutionProcessor(deviceIndex); + Console.WriteLine($"GPU агент: устройство {deviceIndex}, {gpu.accelerator.Name}"); + await foreach (var item in PerformanceProfiler.ConsumeAsync(inputChannel.Reader, "gpu.wait_input", token)) + { + var result = gpu.ConvolveGpu(item.ImageData, config.Kernel, config.Strategy); + item.ImageData = new byte[0, 0]; + using (PerformanceProfiler.Measure("gpu.enqueue_result")) + await outputChannel.Writer.WriteAsync(new ProcessingResult + { + OutputPath = item.OutputPath, + ResultData = result, + ProcessedBy = ProcessorType.GPU + }, token); + result = null!; + } + })); + } + for (int i = 0; i < config.CpuWorkers; i++) + workers.Add(Start(async () => + { + await foreach (var item in PerformanceProfiler.ConsumeAsync(inputChannel.Reader, "cpu.wait_input", token)) + { + var result = ConvolveCpu(item.ImageData, config.Kernel, config.Strategy, config.CpuStrategy); + item.ImageData = new byte[0, 0]; + using (PerformanceProfiler.Measure("cpu.enqueue_result")) + await outputChannel.Writer.WriteAsync(new ProcessingResult + { + OutputPath = item.OutputPath, + ResultData = result, + ProcessedBy = ProcessorType.CPU + }, token); + result = null!; + } + })); + var workerCompletion = Complete(workers.ToArray(), () => outputChannel.Writer.TryComplete()); + var writers = Enumerable.Range(0, config.WriterThreads).Select(_ => Start(async () => + { + await foreach (var result in PerformanceProfiler.ConsumeAsync(outputChannel.Reader, "writer.wait_input", token)) + { + using (PerformanceProfiler.Measure("writer.save")) ImageIO.SaveImageByte(result.ResultData, result.OutputPath); + result.ResultData = new byte[0, 0]; + Interlocked.Increment(ref processedCount); + if (result.ProcessedBy == ProcessorType.CPU) Interlocked.Increment(ref cpuCount); + else Interlocked.Increment(ref gpuCount); + slots.Release(); + } + })).ToArray(); + await Task.WhenAll(writers.Append(readerCompletion).Append(workerCompletion)); + if (failure != null) System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw(); + PerformanceProfiler.Set("completed", new { total = processedCount, cpu = cpuCount, gpu = gpuCount }); + Console.WriteLine($"Обработано {processedCount}/{files.Length}; CPU: {cpuCount}, GPU: {gpuCount}; {timer.Elapsed.TotalMilliseconds:F1} мс"); + } + + public static byte[,] ConvolveCpu(byte[,] image, float[,] kernel, EdgeStrategy strategy, ParallelStrategy parallelStrategy = ParallelStrategy.Sequential) + { + return ConvolutionCore.Convolve(image, kernel, strategy, parallelStrategy); + } + + public void Dispose() + { + lock (sync) + { + disposed = true; + GC.SuppressFinalize(this); + } + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index c972007..0b277e4 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,68 @@ # Описание проекта: Свёртка изображений -Данный проект — это консольное приложение на C#, которое реализует алгоритм математической свёртки для обработки изображений. Программа переводит цветное изображение в градации серого и применяет к нему матричные фильтры. +Данный проект — консольное приложение на C# для обработки изображений. Программа переводит цветное изображение в градации серого и применяет к нему матричный фильтр. В проекте можно сравнить последовательную, параллельную, агентную и GPU-обработку. -## Как работает библиотека (Алгоритм) +## Как работает библиотека (алгоритм) -Основная логика находится в классе `ConvolutionProcessor`. -Алгоритм представляет собой скольжение матрицы (ядра) по каждому пикселю исходного изображения. Новое значение пикселя вычисляется как сумма произведений соседних пикселей на соответствующие веса из матрицы ядра. +Основная логика собственной свёртки находится в классе `ConvolutionProcessor`. Ядро фильтра последовательно накладывается на каждый пиксель. Новое значение вычисляется как сумма произведений соседних пикселей на соответствующие коэффициенты ядра. **Ключевые особенности реализации:** -1. **Любые размеры ядер:** Алгоритм автоматически высчитывает отступы (offset), поэтому поддерживает ядра любых нечётных размеров (3x3, 5x5, 7x7 и т.д.). -2. **Готовые фильтры:** В статическом классе `Kernels` заранее подготовлены матрицы для: - * Размытия (Box Blur) - * Увеличения резкости (Sharpen) - * Выделения контуров (Laplacian) -3. **Обработка краёв (Edge Strategies):** При нахождении ядра на границе изображения применяются две стратегии на выбор: - * `Extend`: дублирование крайнего пикселя (значения за границей приравниваются к ближайшему краю). - * `ZeroPadding`: выход за границы считается абсолютно чёрным цветом (значение 0). -## Последовательная и Параллельная версии +1. **Ядра разных размеров.** Поддерживаются положительные нечётные размеры: 3 × 3, 5 × 5, 7 × 7 и другие. +2. **Готовые фильтры.** В классе `Kernels` определены размытие (BoxBlur), увеличение резкости (Sharpen) и выделение контуров (Laplacian). +3. **Обработка краёв.** `Extend` повторяет крайний пиксель, а `ZeroPadding` считает значения за границей изображения нулевыми. -В проекте реализовано два метода вычислений: -* `Convolve` — классический последовательный проход (4 вложенных цикла). -* `ConvolveParallel` — версия с использованием `Parallel.For`. +Собственная свёртка работает с серым изображением: яркость каждого пикселя хранится в одном байте (0–255). Коэффициенты ядра и промежуточная сумма имеют тип `float`. Цветовые каналы после перевода в серое не сохраняются. -*Примечание к параллельной версии:* Распараллелен только самый внешний цикл (по строкам изображения `y`). Внутренние циклы по ширине и по самому ядру оставлены обычными `for`. ---- +## Последовательная и параллельная версии -## Зависимости и пакеты +- `Convolve` — последовательная свёртка изображения на CPU. +- `ConvolveParallel` — параллельная свёртка по строкам с помощью `Parallel.For`. +- `ConvolveParallelByColumns` — параллельная свёртка по столбцам. + +При обработке папки разные файлы также могут обрабатываться одновременно. В вариантах по строкам и столбцам параллелизм по файлам сочетается с параллелизмом внутри изображения. Есть и полностью последовательный вариант: он читает, сворачивает и сохраняет файлы один за другим. + +## Агентная модель, GPU и ImageSharp + +В CPU-конвейере отдельные агенты читают файлы, выполняют свёртку и сохраняют результаты. Количество читателей, вычислителей и писателей настраивается. В объединённом конвейере вычислителями могут быть как CPU-, так и GPU-агенты. +GPU-свёртка реализована через ILGPU и требует видеокарту с поддержкой NVIDIA CUDA. Для сравнения также доступен библиотечный `BoxBlur` из ImageSharp. В проекте есть несколько вариантов подготовки серого изображения для этого фильтра; они выбираются в меню программы. -**Используемые NuGet-пакеты:** -* `SixLabors.ImageSharp` — для работы с графикой (загрузка, конвертация в массив и сохранение). -* `xUnit` и `coverlet.msbuild` — для модульного тестирования кода. +## Зависимости и пакеты + +Для сборки нужен **.NET SDK 9**. Основные NuGet-пакеты: ---- +- `SixLabors.ImageSharp` — чтение, сохранение и библиотечный фильтр изображений; +- `ILGPU` — вычисления на GPU; +- `BenchmarkDotNet` — отдельные измерения производительности; +- `xUnit` и `coverlet.msbuild` — тесты и покрытие кода. ## Инструкция по сборке и запуску -1. Склонируйте проект и перейдите в папку с кодом: -``` +Склонируйте репозиторий и выполните команды из его корня: + +```bash git clone https://github.com/Vit0GG/ImageConvolution.git -cd ImageConvolution/ImageConvolution -Восстановите все необходимые NuGet-пакеты (скачаются автоматически): +cd ImageConvolution +dotnet restore ImageConvolution.sln +dotnet run --project ImageConvolution/ImageConvolution.csproj -c Release +``` + +После запуска программа покажет меню. Выберите способ обработки и укажите путь к файлу или папке. Для измерений используйте сборку `Release`. Обработанные файлы сохраняются рядом с входной папкой; другую папку можно задать переменной окружения `IMAGECONV_OUTPUT_ROOT`. -dotnet restore -Запустите программу: +Основные измерения выполняются после прогрева по 25 раз. Время обработки папки включает чтение и сохранение файлов; время свёртки одного изображения в памяти измеряется отдельно. Отчёты записываются в `MeasurementResults/` в рабочем каталоге программы. -dotnet run +## Проверка проекта + +```bash +dotnet format ImageConvolution.sln --verify-no-changes +dotnet test ImageConvolution.sln -c Release /p:CollectCoverage=true /p:Threshold=95 /p:ThresholdType=line ``` -Использование: -При запуске программа попросит указать путь к исходному файлу (можно просто перетащить картинку в окно терминала). Программа замерит время работы последовательной и параллельной свёртки, выведет результат в консоль и сохранит обработанное изображение в ту же папку с суффиксом _filtered. + +Порог покрытия строк в CI — 95%. Тесты, требующие CUDA, пропускаются на машине без подходящей видеокарты. + +## Презентации + +- [Презентация: CPU](docs/presentations/cpu.pdf) +- [Презентация: CPU и GPU](docs/presentations/gpu.pdf) + diff --git a/docs/presentations/cpu.pdf b/docs/presentations/cpu.pdf new file mode 100644 index 0000000..4a1c81e Binary files /dev/null and b/docs/presentations/cpu.pdf differ diff --git a/docs/presentations/gpu.pdf b/docs/presentations/gpu.pdf new file mode 100644 index 0000000..94a72a9 Binary files /dev/null and b/docs/presentations/gpu.pdf differ