了解Haskell中的内存碎片

2020-08-22 00:58:58

最近我为Hasura做了一些工作,调查了GraphQL-Engine中一些奇怪的内存行为。在测量内存使用情况时,我们可以询问操作系统(OS)我们的进程使用了多少内存,但是我们也可以使用GHC运行时系统(RTS)的堆分析器。在运行GraphQL-Engine基准之后,操作系统报告的服务器内存使用率远远高于GHC的堆分析器报告的“堆驻留”。这让我们在理解使用GHC编译的程序中的内存分配和内存碎片方面遇到了一些困难。

在这篇博客文章中,我们来看看Haskell堆中的内存碎片以及它是如何产生的。我们来看看碎片如何影响RTS报告的堆驻留和OS报告的内存使用之间的差异。特别地,我们将重点介绍一个程序的病态情况,该程序利用固定的数据,并从高堆驻留转换到相对较低的堆驻留。

在Linux上,有多种方法可以测量进程的内存使用情况,但我们将重点关注虚拟内存驻留集大小(VmRSS)。这可以从/proc/<;pid>;/status中采样,其中<;pid>;是一个进程ID。我们不会详细介绍这一点,但可以说,我们认为VmRSS是我们的Haskell程序的“真实”内存使用情况。

堆驻留是由运行时系统的堆分析器进行的度量。它测量Haskell堆上所有活动数据的大小。VmRSS始终高于堆驻留。原因是堆驻留仅是对Haskell堆上的实时数据的度量,而VmRSS是“全部包含的”。GHC用户指南给出了较高VmRSS的以下原因:

由于配置文件构建而产生的开销。每个堆对象使用额外的2个字,这些字通常不会计入堆配置文件中。

垃圾收集。复制收集器(即,默认收集器)复制所有活动的未固定数据,导致内存使用量达到峰值。

默认情况下,堆配置文件中不计入线程堆栈。使用-xt运行时系统选项进行性能分析时,配置文件中包括堆栈。

程序文本本身、C堆栈、任何“非堆”数据(例如,由外部库分配的数据和由RTS本身分配的数据)和mmap()内存不会计入堆配置文件中。

以下部分是我们为这篇博客文章重点介绍的示例程序。我们稍后将深入讨论细节,但是该程序显示的VmRss比堆驻留高得多,所以让我们考虑一下原因:

通常,堆栈使用率可能很高,您应该使用-xt进行分析来诊断这一点。示例程序的堆栈大小可以忽略不计,因此第3点也不适用。

运行时系统(RTS)是用C语言编写的,它有自己的堆栈和非堆数据,但与我们在Haskell堆上分配的大量数据相比,这是微不足道的。程序文本很小,我们也没有调用任何外来代码,也没有调用任何内存的mmap(),所以第4点不适用。1个。

剩下的第2点当然是适用的,但上面的列表没有提到的另一个原因是:碎片化。

RTS的另一个指标是堆大小。堆大小是Haskell堆的全包式度量。它包括碎片。在我们的示例程序中,VmRS和堆大小大致相等。2比较VmRS和堆大小是检查内存使用情况是否真的在Haskell堆中,或者是否存在第4点中列出的其他问题的好方法。

在我们仔细计算字节时,我们应该明确一个尴尬的情况,即内存有时以10为基数单位(例如1千字节(KB)=1000字节),有时以2为基数单位(例如10千比字节(KiB)=1024字节)。更糟糕的是,基数2单位的符号中使用的“i”(例如,“Kib”)经常被省略,这样它们看起来就像基数10的对应物(例如,“KB”)。令人困惑的是,/proc/<;pid>;/status表示“kB”,但意思是“kib”。Eventlog2html输出显示“G”、“M”和“K”,但表示“GB”、“MB”、“kB”。-dg rts选项的调试输出打印“MB”,但表示“MIB”。3。

除了堆配置文件图(使用eventlog2html生成)之外,博客文章中的所有数字都将以2为基数:Gibibyte(GiB)=1024MiB,Mebibyte(MiB)=1024KiB,Kibibyte(KiB)=1024字节。

让我们考虑一下下面的应用程序,该应用程序分配了一个ByteString列表,然后保留了其中较小的1/10子集:

--Main.hs{-#language BangPatterns#-}{-#Options_GHC-WALL#-}导入控件.Concurrent(ThreadDelay)导入控件.DeepSeq(强制)导入控件.Monad(Form_)导入合格的Data.ByteString作为BS导入系统.Mem(PerformGC)导入System.Environment(GetArgs)main::IO()main=do n<;-read。Head<;$>;getArgs--分配大量ByteString(ByteString用固定的数据支持)let!Superset=force$Take n[BS.singleton x|x<;-Cycle[minBound..。MaxBound]]putStrLn";First Platform Start";Spin 3--仅提取超集的一小部分,并允许超集被垃圾收集。具体保留每10个元素。让subsetFactor=10::int let!subset=force$[x|(x,1)<;-zip SUPERSET(Cycle[1..subsetfactor])]putStrLn";Second Platform Start";Spin(3*subsetfactor)--在这里停止对`subset`进行垃圾收集。打印(长度子集)--旋转并允许堆分析器收集样本。自旋::int->;IO()自旋I=FORM_[1..I](\_->;threadDelay 1>;>;性能GC)。

使用ghc-rtsopts-eventlog-debug Main.hs编译,并使用./main 10000000+rts-s-dg-ht-l--DISABLE-DELAYED-OS-MEMORY-RETURN-rts运行。-HT-l选项生成一个带有内存配置文件的事件日志,我们可以使用eventlog2html对其进行可视化。-dg选项将垃圾收集器统计信息打印到标准错误。稍后将解释--enable-delayed-os-memory-return选项。

考虑一下我们在运行堆配置文件时预期的情况。程序应该分配一些内存,然后稍微旋转一下。接下来,超集被垃圾收集,我们只剩下子集。我们预计堆驻留空间将降至大小的1/10。旋转一段时间后,程序将退出。这正是我们所期待的。下面是堆配置文件显示的内容:

大部分内存是ps、arr_words、:和PlainPtr。这些是在ByteString列表中找到的类型构造函数。Ps是ByteString构造函数,PlainPtr和arr_word在ByteString内部。我们看到,分配超集会产生大约1.04GiB(1.12 GB)的堆驻留时间,对应于堆配置文件中27到39秒之间的第一个平台。在此之后,我们提取该数据子集的1/10,并允许对超集的其余部分进行垃圾收集。因此,我们预计堆驻留空间将降至大小的约1/10,即0.10GiB(0.11 GB),但这不是配置文件所显示的!堆驻留仅减少到约0.37GiB(0.4 GB),并且所有ARR_WORD都意外保留。

这不是代码中的一些细微错误导致ARR_WORD被保留。这实际上是由于RTS处理固定内存的方式。让我们看一下操作系统报告的内存驻留情况。我每0.01秒对/proc/<;pid>;/status中报告的VmRSS进行采样:

VmRSS与堆配置文件有一些差异。操作系统报告在第一个平台期大约有1.84G的内存。这几乎是堆配置文件的1.8倍。与堆配置文件同步,在40到61秒之间,存在第二个平台,其中VmRSS约为1.5GiB。这大约是堆配置文件的4倍。因此,不仅第一个平台上的VmRSS显著高于堆驻留,而且第二个平台上的差异要严重得多。

为了理解内存配置文件,我们需要了解GHC的Haskell堆的结构、分配是如何工作的,以及一些关于垃圾收集的知识。我将对此做一个简单的概述。特别是,我忽略了巨型块/块组、块描述符,并且只考虑了最古老的垃圾收集器代。我还假设正在使用默认的复制垃圾收集器。

Haskell堆由1MiB“巨型块”组成。其中有4KiB的“区块”。在这些块中是实际的数据对象。块被指定为仅包含固定或取消固定的数据。下面是虚拟内存空间中堆可能的样子的示例:

这不是为了扩大规模。实际上,一个巨型块包含更多的块,一个块通常包含更多的对象,并且对象的大小可能会有所不同。请注意,在虚拟内存空间中,巨型块不一定是连续的。我们将巨型块之间未使用的间隙称为“巨型块级碎片”:

同样,兆块内的块之间未使用的间隙称为“块级碎片”:

请注意,一些块的末尾有一些未使用的空间,因为我们还没有在那里添加对象,或者没有足够的空间来添加对象,因此RTS改为分配一个新块。额外的空间称为“SLOP”,我们不将其计入对象级碎片。在这篇文章中,我们通常忽略slop。

什么是固定数据?由于引用透明性,Haskell中对象的内存地址通常并不重要。这允许RTS的默认复制垃圾回收器在内存中移动对象,即更改它们的内存位置。实际上,我们可能需要一块不会被RTS移动的内存块。最明显的例子是将数据传递给外部代码时。如果RTS突然移动该数据,外来代码将不会很高兴。因此,GHC支持可以通过GHC.Exts.newPinnedByteArray#和类似变体分配的“固定”数据的概念。固定的数据保证不会被RTS移动。我们将所有其他数据称为“未固定”。请注意,“大对象”(大于块的8/10的对象)也被视为固定数据。上面示例中使用的ByteString使用隐藏在引擎盖下的固定数据。固定的数据还可以在哈希表和加密包以及向量包中找到,其中固定的数据用于可存储的向量。

内存分配在几个层中进行。在底部是操作系统,RTS最终从该操作系统请求虚拟内存地址空间内的内存。在此之上是RTS的兆块分配器,它以1MiB兆块为单位从操作系统分配内存。巨型块分配器负责分配和释放巨型块,但最终还是由块分配器向巨型块分配器请求新的巨型块,并由垃圾收集器指示巨型块分配器释放巨型块。从技术上讲,只有几个字大小的单个物体就能维持整个巨型块体的生命,这从技术上讲是可能的。巨型块分配器跟踪释放的巨型块,并在分配新的巨型块时重用释放的空间。在分配新的巨型区块(即具有最低内存地址的区块)时,它总是选择最左边的空闲空间。

每个兆块都真正保留了不能被其他进程使用的物理内存的MiB。无论其内容如何,巨型区块的总数最终都是操作系统计入VmRS的数量。还要注意,巨块级碎片并不对应于物理内存的碎片。巨型区块位于虚拟内存空间中。因此,操作系统可以有效地将巨型块映射到物理内存,并且能够在物理内存空间中出现碎片问题时压缩该内存。换句话说,巨型区块碎片并不重要,只有巨型区块的总数计入VmRS。

默认情况下,巨型区块会延迟返回给操作系统。这意味着OS可能延迟释放存储器,例如直到存在存储器压力。这使得重新分配尚未释放的内存成本很低,但这会混淆VmRSS度量。通过使用--停用-延迟-os-memory-return RTS选项,可以立即返回内存,并且VmRSS会密切跟踪巨型块的总数。这提供了更有意义的VmRSS测量,但可能会降低性能,因此不建议在正常使用时使用。

上一级是RTS的块分配器,它以较小的单位从巨型块分配器分配内存,即4KiB块(组)。块分配器跟踪所有的“空闲”块,即块级碎片。在分配块时,如果不存在空闲块,则向巨块分配器请求新的巨块,否则重用释放的块。挑选空闲块不一定是按最低内存顺序进行的(与巨型块不同)。新块的位置受几个因素的影响,包括先前块被释放的模式。我不会尝试详细描述这一点,但我们可以认为新大厦的位置大多是随意的。可以保证的是,如果存在空闲块,则会重用该空闲块,而不是请求新的兆块。

在块分配器之上,程序运行并分配对象。4每个线程具有当前钉住的块和当前未钉住的块。新的锁定/取消锁定的对象将以凹凸指针的方式分别放置在当前锁定/取消锁定的块中。这意味着这些块从左到右紧密地堆放着对象。当当前块被填满时,从块分配器请求新块,并且该过程继续。同样,块分配器将此块分配到内存中可能与前一个块不连续的任意位置。

最终会发生垃圾收集。我假设默认复制收集器正在使用中。垃圾收集器的工作方式是扫描所有块并将实时取消固定的数据复制到新块。在这一点上,我们将达到内存使用的峰值,存储:

接下来,垃圾收集器释放除活动固定块和活动未固定数据的新副本之外的所有块。因此,垃圾收集器已经完成了释放死数据的任务。请注意,在复制实时数据时,在块内以凹凸指针的方式再次执行此操作,根据需要请求新块。因此,实时数据的新拷贝自然是“压缩”的,即在新块中没有对象级碎片。这消除了未固定数据的对象级碎片,从而极大地提高了性能。

最后,垃圾收集器可能会要求块分配器释放一些巨型块。这最终是RTS将内存返回给操作系统的方式。为了增加混乱,垃圾收集器估计下一次垃圾收集需要多少兆块,并且即使可以释放更多的块,也可以避免释放巨块,而不考虑--disable-delayed-os-memory-return标志的情况。在这种情况下,垃圾收集器估计下一次垃圾收集需要多少兆块,并且可以避免释放更多的兆块。这样做是为了避免从操作系统退还/重新分配内存的成本。

由于垃圾回收器释放大量块,因此这些释放的块会导致块级碎片。在实践中,由于频繁地分配对象、定期执行垃圾收集,以及因为新块的位置有点随意,我们最终释放的块往往与其他保留的块散布在一起。

对于固定的块,由于固定的对象不能移动,因此无法压缩,因此在垃圾回收后仍保留对象级别的碎片。更糟糕的是,这些固定块中的空闲空间是不可用的,直到其中的所有对象都死了,并且块作为一个整体被释放。这是因为分配以凹凸指针的方式工作,而不是试图重新填充对象级别的碎片。

垃圾收集后的最终结果是一个包含压缩的未固定块、未压缩固定块和一些块级碎片的堆。下面是一个堆的示例,在垃圾收集之前,在复制实时取消固定的数据之后,然后在垃圾收集之后:

在本例中需要注意的一个关键点是,块级碎片存在于GC周期的开始,它允许我们将所有复制的实时数据放入碎片空间。因此,我们避免了分配更多的兆块,并且VmRS在垃圾收集期间不会增加。事实上,在最后一步中,释放了一个兆块,这意味着VmRSS将减少。

让我们再看一下示例程序。为了方便您,我已将Heap和VmRSS配置文件复制到此处:

让我们看看内存配置文件中的第一个平台期(27s-39s)。我们在保留超集的同时运行自旋。VmRSS约为1.84GiB,而堆驻留约为1.04GiB。这种差异可以通过再次查看-dg输出来解释,5具体地说是块总数和空闲块:

内存清点:...。第1代数据块:281281个数据块(1098.8 MiB)...。免费:191705块(748.8 MiB)...。总计:473256块(1848.7 MiB)。

“总”(即堆大小)大约等于VmRSS,这确认Haskell堆之外的内存可以忽略不计。大多数实时数据都在“Gen 1”中,即1098.8 MiB。这比堆驻留稍多一点,因为它包括斜率。“空闲”块对应于块级碎片,其中有很多,约占块总数的40%。正是这种碎片解释了VmRSS和堆驻留之间的差异。

既然Spin反复调用复制垃圾回收器,为什么VmRSS在整个平稳期间都是稳定的?正如我们在上一节中看到的,这是可能的,因为复制的对象放在碎片的“空闲”块中,因此不一定需要新的巨型块。

请记住,在垃圾收集期间只复制活动的未固定数据。从堆配置文件中,我们可以看到大约305.2MiB(=320MB)的ARR_WORD,它是ByteString使用的固定数据。这样就剩下762.9MiB(=800MB)的实时未固定数据。这几乎符合-DG报告的748.8MiB的空闲块。空闲块的大小大约等于未固定的实时数据的大小,这不是巧合。这相当程度上是以前垃圾收集的结果。

这里有一个问题。我们的实时未固定数据比可用数据块多出约141MiB。因此,我们预计垃圾数据收集会在复制未固定的实时数据时分配更多的兆块,但VmRSS在第一个平台中不会改变。在检测GHC之后,我确认没有分配新的兆块。RTS将这些14.1MiB复制到哪里?这目前还是个谜。

现在我们了解了第一个高原,但是第二个高原更有趣一些。为什么没有任何arr_word对象被垃圾收集呢?我向您保证,它们确实是已死的对象,属于现在已死的或垃圾收集的ByteStrings。6它们出现在堆配置文件中的原因归因于RTS中的一个怪癖。堆分析器不会尝试说明固定块中的死对象,而是将整个固定块视为活动的arr_word,只要其中的任何对象都是活动的。7看待这一点的另一种方式是,固定的对象碎片在堆配置文件中被计为arr_word。

现在的问题是,为什么我们有这么多固定的对象级别碎片。这是因为我们专门从超集“剥离”元素来创建子集:

让我们想象一下,第一个平台中的堆看起来像这样,保留了所有超集:

即使我们的物体有9/10已经死了,我们也只设法释放了大约1/4的巨型积木。问题是固定的块不能移动;它们是固定的。这意味着内部的死对象被保留为固定的对象级别碎片,这解释了堆配置文件中出乎意料的高arr_word。

另一个要点是,即使有足够的未使用空间(即块级碎片),也无法释放包含固定块的巨型块。这意味着固定的块会导致更多。

.