【问题标题】:GC is forced when working with small images (<=4k pixel data)?处理小图像(<=4k 像素数据)时强制使用 GC?
【发布时间】:2023-03-26 15:54:02
【问题描述】:

当通过WriteableBitmap 处理 文件 (

虽然这在小型应用程序中不是一个重大瓶颈,但当内存中存在数千个对象时(例如:EntityFramework 上下文加载了许多实体和关系)。

综合测试:

var objectCountPressure = (
    from x in Enumerable.Range(65, 26)
    let root = new DirectoryInfo((char)x + ":\\")
    let subs = 
        from y in Enumerable.Range(0, 100 * IntPtr.Size)
        let sub =new {DI = new DirectoryInfo(Path.Combine(root.FullName, "sub" + y)), Parent = root}
        let files = from z in Enumerable.Range(0, 400) select new {FI = new FileInfo(Path.Combine(sub.DI.FullName, "file" + z)), Parent = sub}
        select new {sub, files = files.ToList()}
    select new {root, subs = subs.ToList()}
    ).ToList();

const int Size = 32;
Action<int> handler = threadnr => {
    Console.WriteLine(threadnr + " => " + Thread.CurrentThread.ManagedThreadId);
    for (int i = 0; i < 10000; i++)    {
        var wb = new WriteableBitmap(Size, Size, 96, 96, PixelFormats.Bgra32, null);
        wb.Lock();
        var stride = wb.BackBufferStride;
        var blocks = stride / sizeof(int);
        unsafe {
            var row = (byte*)wb.BackBuffer;
            for (int y = 0; y < wb.PixelHeight; y++, row += stride)
            {
                var start = (int*)row;
                for (int x = 0; x < blocks; x++, start++)
                    *start = i;
            }
        }
        wb.Unlock();
        wb.Freeze();     }
};
var sw = Stopwatch.StartNew();
Console.WriteLine("start: {0:n3} ms", sw.Elapsed.TotalMilliseconds);
Parallel.For(0, Environment.ProcessorCount, new ParallelOptions{MaxDegreeOfParallelism = Environment.ProcessorCount}, handler);
Console.WriteLine("stop : {0:n2} s", sw.Elapsed.TotalSeconds);

GC.KeepAlive(objectCountPressure);

我可以使用“const int Size = 48”运行这个测试十几次:它总是在大约 1.5 秒内返回,而“# Induced GC”有时会增加 1 或 2。

当我将“const int Size = 48”更改为“const int Size = 32”时,发生了一件非常非常糟糕的事情:“#Induced GC”每秒增加 10 次,现在总运行时间超过一分钟:~80 秒! [在 8GB RAM 的 Win7x64 Core-i7-2600 上测试 // .NET 4.0.30319.237 ]

WTF!?

要么框架有一个非常严重的错误,要么我做错了什么。

顺便说一句
我不是通过进行图像处理来解决这个问题,而是通过 DataTemplate 对某些数据库实体使用包含图像的工具提示: 这工作正常(快速),而 RAM 中不存在太多对象 - 但是当存在数百万其他对象(完全不相关)时,显示工具提示总是延迟几秒钟,而其他一切都正常工作。

【问题讨论】:

  • 即使为“好”尺寸分配的内存量也比预期的 4*width*height 高很多(10 倍左右)
  • 在 WritableBitmap.GetEstimatedSize 上使用反射器,它是 return (long) ((((pixelWidth * pixelHeight) * pixelFormat.InternalBitsPerPixel) / 8) * 2); -> 所以对于 32x32,它正好是 0x2000 (8kb)。再往下看,SafeMILHandleMemoryPressure 的 ctor 决定在 > 0x2000 处做一些不同的事情——低于或等于 0x2000 它总是调用 GC.AddMemoryPressure(long) ,这似乎总是触发 GC.Collect(2);即使每秒发生一千次。
  • 我怀疑在某些情况下他们忘记将GC.AddMemoryPressure 呼叫与GC.RemoveMemoryPressure 配对。因此内存压力继续上升,一旦超过一定水平,每次调用AddMemoryPressure都会触发一次完整的GC。
  • 有趣的是,当我在 LinqPad 中运行时,效果比运行普通版本的程序时大得多
  • 无法想象这是“设计使然”。它必须是一个错误。我认为将此报告给 MS-CONNECT 是一件好事。

标签: .net wpf garbage-collection


【解决方案1】:

TL;DR:也许最好的解决方案是创建一个WriteableBitmaps 的小池并重复使用它们,而不是创建它们并丢弃它们。

所以我开始研究 WinDbg 以了解导致收集发生的原因。

首先,我在Main 的开头添加了对Debugger.Break() 的调用,以使事情变得更容易。我还添加了我自己对GC.Collect() 的调用作为健全性检查,以确保我的断点工作正常。然后在 WinDbg 中:

0:000> .loadby sos clr
0:000> !bpmd mscorlib.dll System.GC.Collect
Found 3 methods in module 000007feee811000...
MethodDesc = 000007feee896cb0
Setting breakpoint: bp 000007FEEF20E0C0 [System.GC.Collect(Int32)]
MethodDesc = 000007feee896cc0
Setting breakpoint: bp 000007FEEF20DDD0 [System.GC.Collect()]
MethodDesc = 000007feee896cd0
Setting breakpoint: bp 000007FEEEB74A80 [System.GC.Collect(Int32, System.GCCollectionMode)]
Adding pending breakpoints...
0:000> g
Breakpoint 1 hit
mscorlib_ni+0x9fddd0:
000007fe`ef20ddd0 4154            push    r12
0:000> !clrstack
OS Thread Id: 0x49c (0)
Child SP         IP               Call Site
000000000014ed58 000007feef20ddd0 System.GC.Collect()
000000000014ed60 000007ff00140388 ConsoleApplication1.Program.Main(System.String[])

所以断点工作正常,但是当我让程序继续运行时,它再也没有被命中。似乎从更深的地方调用了 GC 例程。接下来我进入GC.Collect() 函数,看看它在调用什么。为了更容易地做到这一点,我在第一个电话之后立即添加了第二个电话 GC.Collect() 并进入了第二个电话。这避免了单步执行所有 JIT 编译:

Breakpoint 1 hit
mscorlib_ni+0x9fddd0:
000007fe`ef20ddd0 4154            push    r12
0:000> p
mscorlib_ni+0x9fddd2:
000007fe`ef20ddd2 4155            push    r13
0:000> p
...
0:000> p
mscorlib_ni+0x9fde00:
000007fe`ef20de00 4c8b1d990b61ff  mov     r11,qword ptr [mscorlib_ni+0xe9a0 (000007fe`ee81e9a0)] ds:000007fe`ee81e9a0={clr!GCInterface::Collect (000007fe`eb976100)}

稍稍前行后,我注意到clr!GCInterface::Collect 的引用听起来很有希望。不幸的是,它从未触发过断点。进一步挖掘GC.Collect() 我发现clr!WKS::GCHeap::GarbageCollect 被证明是真正的方法。对此的断点揭示了触发集合的代码:

0:009> bp clr!WKS::GCHeap::GarbageCollect
0:009> g
Breakpoint 4 hit
clr!WKS::GCHeap::GarbageCollect:
000007fe`eb919490 488bc4          mov     rax,rsp
0:006> !clrstack
OS Thread Id: 0x954 (6)
Child SP         IP               Call Site
0000000000e4e708 000007feeb919490 [NDirectMethodFrameStandalone: 0000000000e4e708] System.GC._AddMemoryPressure(UInt64)
0000000000e4e6d0 000007feeeb9d4f7 System.GC.AddMemoryPressure(Int64)
0000000000e4e7a0 000007fee9259a4e System.Windows.Media.SafeMILHandle.UpdateEstimatedSize(Int64)
0000000000e4e7e0 000007fee9997b97 System.Windows.Media.Imaging.WriteableBitmap..ctor(Int32, Int32, Double, Double, System.Windows.Media.PixelFormat, System.Windows.Media.Imaging.BitmapPalette)
0000000000e4e8e0 000007ff00141f92 ConsoleApplication1.Program.<Main>b__c(Int32)

所以WriteableBitmap的构造函数间接调用GC.AddMemoryPressure,最终导致集合(顺便提一下,GC.AddMemoryPressure是模拟内存使用的更简单的方法)。但是,这并不能解释从 33 到 32 大小时行为的突然变化。

ILSpy 在这里提供帮助。特别是,如果您查看SafeMILHandleMemoryPressure 的构造函数(由SafeMILHandle.UpdateEstimatedSize 调用),您会发现它仅在添加的压力GC.AddMemoryPressure。否则它使用自己的自定义系统进行跟踪内存压力和触发收集。具有 32 位像素的 32x32 位图大小低于此限制,因为 WriteableBitmap 估计内存使用为 32 * 32 * 4 * 2(我不确定为什么会有额外的 2 因子)。

总而言之,您所看到的行为似乎是框架中的启发式方法的结果,该框架不适用于您的案例。 您可以通过创建比您需要的更大尺寸或更大像素格式的位图来解决此问题,以便位图的估计内存大小> 8192。

事后思考:我想这也表明由于GC.AddMemoryPressure 而触发的收集计入“#Induced GC”?

【讨论】:

  • 我在评论@CodeInChaos 时注意到了这个 8192 开关——但为什么 GC.AddMemoryPressure 总是执行 GC.Collect(2)?当我使用大约 64 位时。 1.5-2 GB 的 RAM(其中 8 GB 可用,其中 7 个未使用)然后即使加载相当大小的图像 (640x480) 也会延迟几秒钟,因为 GC.Collect(2) 正在分析包含数千或数百万个的对象图无缘无故的对象。我的应用程序运行良好(分配更多 RAM)如果我没有做任何与图像相关的事情。你能确认这是某种应该发布到 MS-CONNECT 的错误吗?
  • 你能算出AddMemoryPressure 被调用的频率与RemoveMemoryPressure 被调用的频率相比吗?
  • 你是对的,AddMemoryPressure 似乎正在触发完整的收集,无论使用多少内存。用 MS 提出问题听起来是个好主意。
  • 我刚刚在 LINQPad 中对此进行了测试:var ctr = 0; while(true) { GC.AddMemoryPressure(8192); if (++ctr % 163840 == 0) Console.WriteLine(ctr); } 1 秒后诱导的 GC 计数器跃升至 150,但随后每秒​​缓慢增加 2-3。我在 5 秒后停了下来,最后一行显示“332595200”——这与每次 AddMemoryPressure 调用 1 个完整的集合相去甚远。
  • 在我的测试中,原始程序总共只触发了大约 100 个集合。这确实意味着每几百次迭代才会触发一次 GC。
【解决方案2】:

在所有SafeMILHandleMemoryPressureSafeMILHandle 废话之下是对MS.Internal.MemoryPressure 上的方法的调用,该方法使用静态字段“_totalMemory”来跟踪WPF 认为分配了多少内存。当它达到(相当小的)限制时,诱导的 GC 开始并且永远不会结束。

您可以使用一点反射魔法完全阻止 WPF 以这种方式运行;只需将 _totalMemory 设置为适当的负数,这样就永远不会达到限制并且不会发生诱导的 GC:

typeof(BitmapImage).Assembly.GetType("MS.Internal.MemoryPressure")
    .GetField("_totalMemory", BindingFlags.NonPublic | BindingFlags.Static)
    .SetValue(null, Int64.MinValue / 2);

【讨论】:

  • 虽然我通常不喜欢基于字符串的反射工作——尤其是在私有成员上——但我肯定会尝试一下。
  • 很高兴 5 年后,从 .net 4.6.2 开始,Microsoft 完全删除了这个废话 MemoryPressure 类,所以现在不再有强制垃圾收集,也不再需要这种 hack。
  • 这会有什么危险的副作用吗?为什么会有诱导的 GC?
  • 我很惊讶这仍然相关,但我工作的一家公司在我离开前几个月在生产环境中非常依赖它,我认为他们在我离开后继续使用它。我们的假设是,这只是某个实习生之类的新手错误。
【解决方案3】:

在 Win7 x86(T4300、2.1GHz、3GB)上运行 Markus 的代码:
(注意 33 和 32 之间的巨大差异)

Is64BitOperatingSystem: 假
Is64BitProcess: 假
版本:4.0.30319.237

以 40: 3,20 s 运行测试
34 运行测试:1,14 秒
33 运行测试:1,06 秒
用 32 运行测试:64,41 秒

以 30 运行测试:53,32 秒
用 24 运行测试:29,01 s

另一台机器 Win7 x64 (Q9550, 2.8GHz, 8GB):

Is64BitOperatingSystem: True
Is64BitProcess: 假
版本:4.0.30319.237

以 40: 1,41 s 运行测试
使用 34 进行运行测试:1.24 秒
使用 33 进行运行测试:1.19 秒
用 32 运行测试:1.554,45 s

用 30 运行测试:1.489,31 s
以 24 秒运行测试:842,66 秒
再次以 40 秒运行测试:7,21 秒

Q9550 CPU 的功率比 T4300 大得多,但它运行在 64 位操作系统上。
这似乎减慢了整个过程。

【讨论】:

  • @Steven:你是对的 - 但格式化 cmets 几乎是不可能的 ;-)
  • 我想知道在 .NET 2.0 上运行它时的数字是多少。
  • 您的某些测试用了半小时才完成? wtf
  • @Sascha:OP的问题和Tasks有什么关系?
  • 对于 .NET 2,您可以将 Action&lt;int&gt; handler 替换为 ParameterizedThreadStart handler 并将包含 Parallel.For 的行替换为 var threads = Enumerable.Range(1, Environment.ProcessorCount).Select(i =&gt; new Thread(handler)).ToList(); for (int i = 0; i &lt; threads.Count; i++) threads[i].Start(i); foreach(var thread in threads) thread.Join(); -- 那么它也应该在那里工作。
【解决方案4】:

试试这个简单的解决方法:

调用GC.AddMemoryPressure(128 * 1024)一次,会麻痹内存压力机制。

如果还不够麻木,就给一个更大的数字。

【讨论】:

    猜你喜欢
    • 2016-01-19
    • 2020-02-24
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-05-10
    • 2023-03-22
    • 1970-01-01
    相关资源
    最近更新 更多