如何在Haskell中针对软实时应用程序优化垃圾回收?

16
我已用Haskell编写了一个软实时应用程序,处理模拟物理、碰撞检测等功能。在这个过程中,我使用了大量的内存,如果需要的话,我可以优化我的内存使用。但是,由于我目前的CPU利用率为40%,只使用了1%的RAM,所以似乎没有必要。不过问题在于,当垃圾回收器开始工作时,很多时候都会出现跳帧的情况。通过使用threadscope进行性能分析后,我证实了这是问题的根本原因:在执行有用计算之前,垃圾回收器偶尔需要长达0.05秒的时间,导致最多跳过3帧,这非常明显和非常恼人。

现在,我尝试通过每一帧手动调用performMinorGC来解决这个问题,这似乎缓解了这个问题,使它变得更加平滑,但总体CPU使用率急剧上升到70%左右。显然,我宁愿避免这种情况。

我尝试的另一件事是,将GC的分配空间从512k减少到64k,并尝试设置-I0.03以尽可能频繁地进行回收。这两个选项都改变了我在threadscope中看到的垃圾收集模式,但仍导致跳帧。

有经验的GC优化人士能帮我解决这个问题吗?我是否注定要手动调用performMinorGC并忍受由此产生的巨大性能损失?

编辑

我尝试在这些测试中运行了相似数量的时间,但因为它是实时的,所以没有任何时刻是“完成”的。

每4帧使用performMinorGC的运行时统计信息:

     9,776,109,768 bytes allocated in the heap
     349,349,800 bytes copied during GC
      53,547,152 bytes maximum residency (14 sample(s))
      12,123,104 bytes maximum slop
             105 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0     15536 colls, 15536 par    3.033s   0.997s     0.0001s    0.0192s
  Gen  1        14 colls,    13 par    0.207s   0.128s     0.0092s    0.0305s

  Parallel GC work balance: 6.15% (serial 0%, perfect 100%)

  TASKS: 20 (2 bound, 13 peak workers (18 total), using -N4)

  SPARKS: 74772 (20785 converted, 0 overflowed, 0 dud, 38422 GC'd, 15565 fizzled)

  INIT    time    0.000s  (  0.001s elapsed)
  MUT     time    9.773s  (  7.368s elapsed)
  GC      time    3.240s  (  1.126s elapsed)
  EXIT    time    0.003s  (  0.004s elapsed)
  Total   time   13.040s  (  8.499s elapsed)

  Alloc rate    1,000,283,400 bytes per MUT second

  Productivity  75.2% of total user, 115.3% of total elapsed

gc_alloc_block_sync: 29843
whitehole_spin: 0
gen[0].sync: 11
gen[1].sync: 71

没有performMinorGC

  12,316,488,144 bytes allocated in the heap
     447,495,936 bytes copied during GC
      63,556,272 bytes maximum residency (15 sample(s))
      15,418,296 bytes maximum slop
             146 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0     19292 colls, 19292 par    2.613s   0.950s     0.0000s    0.0161s
  Gen  1        15 colls,    14 par    0.237s   0.165s     0.0110s    0.0499s

  Parallel GC work balance: 2.67% (serial 0%, perfect 100%)

  TASKS: 17 (2 bound, 13 peak workers (15 total), using -N4)

  SPARKS: 100714 (29688 converted, 0 overflowed, 0 dud, 47577 GC'd, 23449 fizzled)

  INIT    time    0.000s  (  0.001s elapsed)
  MUT     time   13.377s  (  9.917s elapsed)
  GC      time    2.850s  (  1.115s elapsed)
  EXIT    time    0.000s  (  0.006s elapsed)
  Total   time   16.247s  ( 11.039s elapsed)

  Alloc rate    920,744,995 bytes per MUT second

  Productivity  82.5% of total user, 121.4% of total elapsed

gc_alloc_block_sync: 68533
whitehole_spin: 0
gen[0].sync: 9
gen[1].sync: 147

由于某种原因,与我昨天测试时相比,现在没有使用performMinorGC的总体生产力似乎更低了——以前总是大于90%。


1
请粘贴运行时统计信息(+RTS -s)。 - Yuras
1
天真的建议,如果你每10帧调用“performMinorGC”呢? - Erik Kaplun
1
你正在分配什么?如果能避免分配,垃圾回收就不再是一个问题。 - MathematicalOrchid
1
@PaulJohnson 我的碰撞检测比那个稍微简单一些,将区域分成固定大小的网格,然后只需循环遍历每个网格单元中的所有内容。它是2D的,所以对我来说完全可以正常工作。但是,避免分配内存会非常困难,至少不会牺牲很多优雅。我试图使代码对初学者来说相当易读,因此我宁愿不放弃我的许多好抽象。 - Bradley Hardy
1
每秒分配1GB?哇...模拟有多大?无论如何,可能存在某种懒惰/严格性问题,导致您消耗比必要更多的RAM。除此之外,我不确定该建议什么... - MathematicalOrchid
显示剩余3条评论
1个回答

4
您的老生代非常大,达到了100Mb。
默认情况下,GHC在上一次进行主要GC后堆大小达到2倍时执行主要GC。这意味着在某个时刻,GC必须扫描和复制50Mb的数据。如果您的处理器具有10Gb内存吞吐量限制,则加载和复制50Mb将至少需要0.01秒(与gen1平均和最大暂停时间进行比较)。
(我假设您已经检查了事件日志,以确保在0.05秒暂停期间实际工作的是主要GC。因此,这不是线程同步问题,在这种情况下,GC正在等待其他线程而不是进行真正的工作。)
因此,为了最小化GC暂停,您应该确保老生代很小。如果这50Mb中的大部分是在最开始分配并一直存活到结束的静态数据(例如纹理或网格),那么您就会卡住。我知道的唯一解决方法是将数据打包到可存储的向量中,并在需要时再解压缩其部分。
如果数据在执行期间分配并且存活时间有限(但足够生存几个主要的代),那么请重新考虑你的流程。通常情况下,没有数据应该存活超过一个帧,所以你做错了什么。例如,你在不应该保留数据时保留了数据。
另一个不好的迹象是gen0最大暂停0.02秒。这非常奇怪。默认情况下,gen0分配区域为0.5Mb,因此gen0 GC应该很快。可能你有很多remembered set。可能的原因是可变结构(IORef、可变向量等)或大量的惰性thunk更新。
还有一个小问题(可能与此无关):看起来你正在使用隐式并行性,但只有三分之一的sparks被转换。你分配了太多的sparks,其中一半被GC回收。

好的,基于这个,我进行了一些实验,并最终注释掉了除主循环旋转之外的所有逻辑代码。结果发现,如果我这样做,分配速率可以达到3.3GB/s。因此,我实现主循环的方式中有一些问题,每次迭代都会分配大量内存...我想我不应该感到惊讶,当GC需要处理如此多的内容时,会导致如此大的暂停!我猜这一定与我的变压器堆栈有关。我将尝试隔离问题。既然答案告诉我出了什么问题,我就接受它。 - Bradley Hardy
更新:将 threadDelay 100 插入主循环中极大地缓解了问题。事实证明,由于我在单独的线程中进行渲染,大多数迭代中主循环只是不断地旋转而没有真正执行任何工作。然而,threadDelay 看起来有点像一个 hack... - Bradley Hardy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接