diff --git a/TIASshot.Tests/ConvertImageMemoryBenchmark.cs b/TIASshot.Tests/ConvertImageMemoryBenchmark.cs new file mode 100644 index 0000000..4ed4231 --- /dev/null +++ b/TIASshot.Tests/ConvertImageMemoryBenchmark.cs @@ -0,0 +1,197 @@ +using System; +using System.Diagnostics; +using NUnit.Framework; +using OpenCvSharp; + +namespace TIASshot.Tests { + /// + /// PLAN_02 優先度2(ConvertImage の中間 Mat 未 Dispose)修正の効果検証ベンチマーク. + /// + /// 修正前(リーク版)と修正後(using 版)の ConvertImage を同条件で繰り返し呼び出し, + /// プロセスのメモリ使用量(PrivateMemorySize64 / WorkingSet64)を比較する. + /// + /// 注意: + /// - OpenCvSharp の Mat はネイティブメモリを持つ.未 Dispose の Mat は GC+ファイナライザが + /// 走るまで解放されない.連続撮影の本番フローでは GC 前に確保が積み上がり OOM クラッシュする. + /// 本ベンチはループ中に GC を呼ばないことでこの機序を再現する. + /// - 通常のテスト実行では走らない([Explicit] + Category("Benchmark")). + /// 実行は vstest の TestCaseFilter で明示指定する(README 末尾の実行コマンド参照). + /// + /// 環境変数でスケール調整可: + /// BENCH_WIDTH 画像幅 (既定 2440) + /// BENCH_HEIGHT 画像高 (既定 1840) + /// BENCH_CHANNEL 拡張チャネル数 (既定 17) + /// BENCH_FRAMES 繰り返し回数 (既定 40) + /// BENCH_CEILING_GB 打ち切り上限GB (既定 10) ← リーク版がここに達したら「限界」として停止 + /// + [TestFixture] + [Explicit("メモリ計測ベンチマーク.フルスケールで大量のメモリを確保するため明示実行のみ.")] + [Category("Benchmark")] + public class ConvertImageMemoryBenchmark { + + static int EnvInt(string name, int fallback) { + var v = Environment.GetEnvironmentVariable(name); + return int.TryParse(v, out var n) ? n : fallback; + } + + static double EnvDouble(string name, double fallback) { + var v = Environment.GetEnvironmentVariable(name); + return double.TryParse(v, out var n) ? n : fallback; + } + + /// 現在のプロセスのメモリ使用量をサンプリングする. + static (long priv, long ws) Sample() { + using (var p = Process.GetCurrentProcess()) { + p.Refresh(); + return (p.PrivateMemorySize64, p.WorkingSet64); + } + } + + static double GB(long bytes) => bytes / (1024.0 * 1024.0 * 1024.0); + + /// 未参照 Mat のネイティブメモリを GC+ファイナライザで回収する. + static void ReclaimNative() { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + /// + /// 修正前(リーク版)の ConvertImage を忠実に再現したもの. + /// 中間 Mat(flatten / extended / converted / convertedImage)を Dispose しない. + /// (git 0003cc2^ の ColorCorrector.ConvertImage(Mat,Mat) と等価) + /// + static Mat ConvertImageLeaky(ColorCorrector corrector, Mat src, Mat conv) { + if (src.Type() != MatType.CV_64FC3) { + src.ConvertTo(src, MatType.CV_64FC3); + } + var flatten = src.Reshape(3, src.Height * src.Width); + var extended = corrector.ExtendMat(flatten, conv.Rows); + var converted = (extended * conv).ToMat(); + var convertedImage = converted.Reshape(3, src.Height); + + var convImg8 = new Mat(); + convertedImage.ConvertTo(convImg8, MatType.CV_8UC3); + return convImg8; // flatten / extended / converted / convertedImage は放置(=リーク) + } + + [Test] + public void Compare_LeakyVsFixed_MemoryUsage() { + int width = EnvInt("BENCH_WIDTH", 2440); + int height = EnvInt("BENCH_HEIGHT", 1840); + int channel = EnvInt("BENCH_CHANNEL", 17); + int frames = EnvInt("BENCH_FRAMES", 40); + long ceiling = (long)(EnvDouble("BENCH_CEILING_GB", 10.0) * 1024 * 1024 * 1024); + + var corrector = new ColorCorrector(null, null, new int[] { channel }); + + // 変換行列(channel x 3).値は任意(メモリ挙動に影響しない). + using (var conv = new Mat(channel, 3, MatType.CV_64FC1, new Scalar(0.01))) { + + TestContext.WriteLine("=== ConvertImage メモリ計測ベンチマーク(PLAN_02 優先度2) ==="); + TestContext.WriteLine($"画像: {width}x{height} CV_8UC3 / チャネル: {channel} / 繰り返し: {frames} 回"); + TestContext.WriteLine($"打ち切り上限: {GB(ceiling):F1} GB (PrivateMemory)"); + TestContext.WriteLine(""); + + // --- 修正後(using 版)を先にクリーンな状態で計測 --- + var fixedResult = RunVariant( + "修正後(using 版)", + (src) => corrector.ConvertImage(src, conv), + width, height, frames, ceiling); + + // リーク版の前にネイティブメモリを回収して baseline を揃える + ReclaimNative(); + + // --- 修正前(リーク版)を計測 --- + var leakyResult = RunVariant( + "修正前(リーク版)", + (src) => ConvertImageLeaky(corrector, src, conv), + width, height, frames, ceiling); + + // 放置したネイティブメモリを後始末(後続テストへの影響を防ぐ) + ReclaimNative(); + + // --- 比較サマリ --- + TestContext.WriteLine(""); + TestContext.WriteLine("================ 比較サマリ ================"); + PrintRow("項目", "修正前(リーク版)", "修正後(using 版)"); + PrintRow("完了/打ち切り", + leakyResult.Aborted ? $"打ち切り(iter {leakyResult.Iterations})" : $"完走({leakyResult.Iterations})", + fixedResult.Aborted ? $"打ち切り(iter {fixedResult.Iterations})" : $"完走({fixedResult.Iterations})"); + PrintRow("Private baseline", $"{GB(leakyResult.BasePriv):F2} GB", $"{GB(fixedResult.BasePriv):F2} GB"); + PrintRow("Private peak", $"{GB(leakyResult.PeakPriv):F2} GB", $"{GB(fixedResult.PeakPriv):F2} GB"); + PrintRow("Private 増分(peak-base)", $"{GB(leakyResult.PeakPriv - leakyResult.BasePriv):F2} GB", $"{GB(fixedResult.PeakPriv - fixedResult.BasePriv):F2} GB"); + PrintRow("WorkingSet peak", $"{GB(leakyResult.PeakWs):F2} GB", $"{GB(fixedResult.PeakWs):F2} GB"); + PrintRow("1回あたり平均増分", + $"{(leakyResult.Iterations > 0 ? GB(leakyResult.PeakPriv - leakyResult.BasePriv) / leakyResult.Iterations * 1024 : 0):F0} MB", + $"{(fixedResult.Iterations > 0 ? GB(fixedResult.PeakPriv - fixedResult.BasePriv) / fixedResult.Iterations * 1024 : 0):F0} MB"); + TestContext.WriteLine("============================================"); + + var leakyGrowth = leakyResult.PeakPriv - leakyResult.BasePriv; + var fixedGrowth = fixedResult.PeakPriv - fixedResult.BasePriv; + TestContext.WriteLine(""); + TestContext.WriteLine( + $"判定: 修正後の増分({GB(fixedGrowth):F2} GB) は 修正前の増分({GB(leakyGrowth):F2} GB) の " + + $"{(leakyGrowth > 0 ? (double)fixedGrowth / leakyGrowth * 100 : 0):F1}% に抑制された."); + + // この閾値判定はあくまで「修正が効いていること」の目安(緩め). + Assert.That(fixedGrowth, Is.LessThan(leakyGrowth), + "修正後の方がメモリ増分が小さいこと(修正が効いている)"); + } + } + + class VariantResult { + public long BasePriv, PeakPriv, FinalPriv, PeakWs; + public int Iterations; + public bool Aborted; + } + + /// + /// 1 つの ConvertImage バリアントを frames 回繰り返し,メモリ推移を計測する. + /// src は in-place 書き換え(CV_64FC3)副作用があるため毎回新規生成して using で破棄する. + /// 戻り値(convImg8)は呼び出し側相当で破棄する(中間 Mat のリークだけを切り出すため). + /// + VariantResult RunVariant(string label, Func convert, int width, int height, int frames, long ceiling) { + ReclaimNative(); + var (basePriv, baseWs) = Sample(); + var r = new VariantResult { BasePriv = basePriv, PeakPriv = basePriv, PeakWs = baseWs }; + + TestContext.WriteLine($"--- {label} ---"); + TestContext.WriteLine($"baseline: Private {GB(basePriv):F2} GB / WS {GB(baseWs):F2} GB"); + + int i = 0; + for (; i < frames; i++) { + using (var src = new Mat(height, width, MatType.CV_8UC3, new Scalar(50, 100, 150))) + using (var dst = convert(src)) { + // dst は using で破棄(呼び出し側 SaveImages の using 相当) + } + + var (priv, ws) = Sample(); + if (priv > r.PeakPriv) r.PeakPriv = priv; + if (ws > r.PeakWs) r.PeakWs = ws; + + if ((i + 1) % 5 == 0 || i == 0) { + TestContext.WriteLine($" iter {i + 1,3}: Private {GB(priv):F2} GB / WS {GB(ws):F2} GB"); + } + + if (priv >= ceiling) { + TestContext.WriteLine($" ★ 上限 {GB(ceiling):F1} GB に到達.iter {i + 1} で打ち切り(リーク蓄積の限界)."); + r.Aborted = true; + i++; + break; + } + } + + var (finalPriv, _) = Sample(); + r.FinalPriv = finalPriv; + r.Iterations = i; + TestContext.WriteLine($"結果: iter {i} / peak Private {GB(r.PeakPriv):F2} GB / peak WS {GB(r.PeakWs):F2} GB / 増分 {GB(r.PeakPriv - r.BasePriv):F2} GB"); + TestContext.WriteLine(""); + return r; + } + + static void PrintRow(string a, string b, string c) { + TestContext.WriteLine($"{a,-24} | {b,-22} | {c,-22}"); + } + } +} diff --git a/TIASshot.Tests/TIASshot.Tests.csproj b/TIASshot.Tests/TIASshot.Tests.csproj index ba327ef..fb57e33 100644 --- a/TIASshot.Tests/TIASshot.Tests.csproj +++ b/TIASshot.Tests/TIASshot.Tests.csproj @@ -59,6 +59,7 @@ +