From 729b9b699d3692b5ef153788a26d3c2b018409ae Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Tue, 2 Jun 2026 09:43:20 +0900 Subject: [PATCH] =?UTF-8?q?refactor(P6):=20Independent=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EB=B3=91=EB=A0=AC=ED=99=94=20+=20Magick=20Resource?= =?UTF-8?q?Limits=20(=EC=A6=89=ED=9A=A8=20=EC=B5=9C=EC=A0=81=ED=99=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 순차 for-loop(멀티코어를 1코어만 사용)을 코어 수 병렬로 — 50장 배치가 코어 수만큼 가속. ConvertOptions 불변(P4)이라 공유 읽기 스레드 안전. 시그니처 불변(내부 교체). - ConversionEngine.ConvertManyAsync: Independent 분기를 Parallel.ForEachAsync(MaxDOP=BatchParallelism)로. 결과는 인덱스 고정 ConvertResult[]로 순서 보존. 진행률은 완료 건수(Interlocked.Increment)로 보고 (파일별 분수 진행률은 동시 실행에서 의미가 흐려짐). DOP<=1이면 기존 순차 경로(세부 진행률 보존). CombineToSingle은 단일 연산이라 병렬 대상 아님(기존대로). - ConvertOptions.BatchParallelism(기본 = 논리 코어 수) 추가 — 미디어 위주 배치는 낮춰 튜닝 가능. - MagickProvider 정적 생성자: ResourceLimits.LimitMemory(60%) — 병렬 시 동시 MagickImage OOM 방지 (초과분 디스크 스필, 출력 바이트 무영향). - 신규 테스트: 8장 병렬 배치의 순서 보존(results[i]↔sources[i]) + 전수 변환 검증. 71개 테스트 전부 그린(골든마스터 동일성 유지), 빌드 0경고/0오류. ⚠ 진행률 UX(완료 건수 모델) 변경 — GUI 스모크 권장. --- .../ConversionEngine.cs | 48 ++++++++++++++----- .../ConvertOptions.cs | 3 ++ .../Converters/MagickProvider.cs | 7 +++ .../EngineCharacterizationTests.cs | 28 +++++++++++ 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/src/Everything2Everything.Core/ConversionEngine.cs b/src/Everything2Everything.Core/ConversionEngine.cs index f73a966..43508a4 100644 --- a/src/Everything2Everything.Core/ConversionEngine.cs +++ b/src/Everything2Everything.Core/ConversionEngine.cs @@ -52,22 +52,44 @@ public sealed class ConversionEngine return new[] { ConvertResult.Fail(sourceList[0], unsupportedReason ?? "단일 파일 결합을 지원하지 않습니다.") }; } - var results = new List(sourceList.Count); - for (var i = 0; i < sourceList.Count; i++) + // 결과는 입력 인덱스로 고정해 병렬 실행에도 순서를 보존한다. + var results = new ConvertResult[sourceList.Count]; + + // 독립(Independent) 배치는 코어 수만큼 병렬화 — ConvertOptions가 불변(P4)이라 공유 읽기가 안전하다. + var dop = Math.Clamp(options.BatchParallelism, 1, sourceList.Count); + + if (dop <= 1) { - cancellationToken.ThrowIfCancellationRequested(); - - var source = sourceList[i]; - progress?.Report(new ConvertProgress(i, sourceList.Count, source, 0)); - - var result = await ConvertOneAsync(source, outputExtension, options, - new Progress(p => progress?.Report(new ConvertProgress(i, sourceList.Count, source, p))), - cancellationToken).ConfigureAwait(false); - - results.Add(result); - progress?.Report(new ConvertProgress(i + 1, sourceList.Count, source, 1)); + // 단일 파일/순차 경로 — 기존 동작과 동일(파일별 세부 진행률 보존). + for (var i = 0; i < sourceList.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + var source = sourceList[i]; + progress?.Report(new ConvertProgress(i, sourceList.Count, source, 0)); + results[i] = await ConvertOneAsync(source, outputExtension, options, + new Progress(p => progress?.Report(new ConvertProgress(i, sourceList.Count, source, p))), + cancellationToken).ConfigureAwait(false); + progress?.Report(new ConvertProgress(i + 1, sourceList.Count, source, 1)); + } + return results; } + // 병렬 경로 — 파일별 분수 진행률은 동시 실행에서 의미가 흐려지므로 완료 건수(Interlocked)로 보고한다. + var completed = 0; + var parallelOptions = new ParallelOptions + { + MaxDegreeOfParallelism = dop, + CancellationToken = cancellationToken, + }; + + await Parallel.ForEachAsync(Enumerable.Range(0, sourceList.Count), parallelOptions, async (i, ct) => + { + var source = sourceList[i]; + results[i] = await ConvertOneAsync(source, outputExtension, options, null, ct).ConfigureAwait(false); + var done = Interlocked.Increment(ref completed); + progress?.Report(new ConvertProgress(done - 1, sourceList.Count, source, 1)); + }).ConfigureAwait(false); + return results; } diff --git a/src/Everything2Everything.Core/ConvertOptions.cs b/src/Everything2Everything.Core/ConvertOptions.cs index ef3220d..34eb93a 100644 --- a/src/Everything2Everything.Core/ConvertOptions.cs +++ b/src/Everything2Everything.Core/ConvertOptions.cs @@ -68,6 +68,9 @@ public sealed record ConvertOptions /// 래스터화 같은 큰 손실 엣지를 회피한다. public bool AvoidLossy { get; init; } = false; + /// 독립(Independent) 배치 변환의 최대 병렬 수. 기본 = 논리 코어 수. 미디어(FFmpeg) 위주 배치는 낮춰 오버서브스크립션 회피. + public int BatchParallelism { get; init; } = Environment.ProcessorCount; + /// 영상 인코딩 시 GPU 하드웨어 가속(NVENC)을 우선 시도하고, 실패하면 CPU로 자동 폴백한다. public bool VideoPreferGpu { get; init; } = true; diff --git a/src/Everything2Everything.Core/Converters/MagickProvider.cs b/src/Everything2Everything.Core/Converters/MagickProvider.cs index f422d9b..5ffdcbc 100644 --- a/src/Everything2Everything.Core/Converters/MagickProvider.cs +++ b/src/Everything2Everything.Core/Converters/MagickProvider.cs @@ -11,6 +11,13 @@ public sealed class MagickProvider : IConverterProvider, IMultiInputConverter ".pdf", ".tif", ".tiff", ".gif", }; + static MagickProvider() + { + // 병렬 배치(P6)에서 동시 MagickImage가 늘어날 때 OOM/temp 스래싱을 막기 위해 메모리 상한 설정 + // (초과분은 디스크로 스필 — 출력 바이트에는 영향 없음). 환경에 따라 미지원이면 무시. + try { ResourceLimits.LimitMemory(new Percentage(60)); } catch { } + } + private static readonly string[] SingleFrameInputs = { ".png", ".bmp", ".jpg", ".jpeg", ".jpe", ".webp", ".avif", ".psd", diff --git a/src/Everything2Everything.Tests/EngineCharacterizationTests.cs b/src/Everything2Everything.Tests/EngineCharacterizationTests.cs index 57457a2..1eb6c8d 100644 --- a/src/Everything2Everything.Tests/EngineCharacterizationTests.cs +++ b/src/Everything2Everything.Tests/EngineCharacterizationTests.cs @@ -270,6 +270,34 @@ public class EngineCharacterizationTests Assert.True(img.HasAlpha); // png self-edge 최적화는 alpha 채널을 보존해야 한다 } + // ── 시나리오 11: Independent 배치 병렬 변환 — 순서 보존 + 전수 변환 (P6) ────────────────────── + [Fact] + public async Task IndependentBatch_Parallel_PreservesOrderAndConvertsAll() + { + var dir = TempDir(); + var colors = new[] + { + MagickColors.Red, MagickColors.Lime, MagickColors.Blue, MagickColors.Yellow, + MagickColors.Cyan, MagickColors.Magenta, MagickColors.White, MagickColors.Black, + }; + var sources = new List(); + for (var i = 0; i < colors.Length; i++) + sources.Add(MakeSolidPng(dir, $"c{i}.png", colors[i], 24, 24)); + + var engine = Everything2EverythingBootstrap.CreateDefault(); + var results = await engine.ConvertManyAsync(sources, ".jpg", CustomOut(dir), batchMode: BatchMode.Independent); + + Assert.Equal(sources.Count, results.Count); + for (var i = 0; i < sources.Count; i++) + { + // 병렬이어도 results[i]는 sources[i]의 결과여야 한다(인덱스 고정 순서 보존). + Assert.Equal(ConvertStatus.Success, results[i].Status); + Assert.Equal(sources[i], results[i].SourcePath); + Assert.Single(results[i].OutputPaths); + Assert.True(File.Exists(results[i].OutputPaths[0])); + } + } + /// combine→pdf 경로로 2페이지 PDF를 만든다(순수 .NET, Ghostscript 불필요). private static async Task BuildTwoPagePdf(string dir) {