减少Haskell程序中的垃圾收集暂停时间


130

我们正在开发一个程序,该程序可以接收和转发“消息”,同时保留这些消息的临时历史记录,以便在需要时可以告诉您消息的历史记录。消息是通过数字标识的,通常大小约为1 KB,我们需要保留成百上千的此类消息。

我们希望针对延迟优化此程序:发送和接收消息之间的时间必须小于10毫秒。

该程序用Haskell编写,并由GHC编译。但是,我们发现,垃圾回收暂停对于我们的延迟要求来说太长了:在我们的实际程序中超过100毫秒。

以下程序是我们应用程序的简化版本。它使用a Data.Map.Strict来存储消息。消息ByteString由标识Int。1,000,000条消息以递增的数字顺序插入,并且最旧的消息不断被删除,以使历史记录最多保留200,000条消息。

module Main (main) where

import qualified Control.Exception as Exception
import qualified Control.Monad as Monad
import qualified Data.ByteString as ByteString
import qualified Data.Map.Strict as Map

data Msg = Msg !Int !ByteString.ByteString

type Chan = Map.Map Int ByteString.ByteString

message :: Int -> Msg
message n = Msg n (ByteString.replicate 1024 (fromIntegral n))

pushMsg :: Chan -> Msg -> IO Chan
pushMsg chan (Msg msgId msgContent) =
  Exception.evaluate $
    let
      inserted = Map.insert msgId msgContent chan
    in
      if 200000 < Map.size inserted
      then Map.deleteMin inserted
      else inserted

main :: IO ()
main = Monad.foldM_ pushMsg Map.empty (map message [1..1000000])

我们使用以下命令编译并运行了该程序:

$ ghc --version
The Glorious Glasgow Haskell Compilation System, version 7.10.3
$ ghc -O2 -optc-O3 Main.hs
$ ./Main +RTS -s
   3,116,460,096 bytes allocated in the heap
     385,101,600 bytes copied during GC
     235,234,800 bytes maximum residency (14 sample(s))
     124,137,808 bytes maximum slop
             600 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0      6558 colls,     0 par    0.238s   0.280s     0.0000s    0.0012s
  Gen  1        14 colls,     0 par    0.179s   0.250s     0.0179s    0.0515s

  INIT    time    0.000s  (  0.000s elapsed)
  MUT     time    0.652s  (  0.745s elapsed)
  GC      time    0.417s  (  0.530s elapsed)
  EXIT    time    0.010s  (  0.052s elapsed)
  Total   time    1.079s  (  1.326s elapsed)

  %GC     time      38.6%  (40.0% elapsed)

  Alloc rate    4,780,213,353 bytes per MUT second

  Productivity  61.4% of total user, 49.9% of total elapsed

此处的重要指标是0.0515秒(即51毫秒)的“最大暂停”。我们希望将其减少至少一个数量级。

实验表明,GC暂停的时间由历史记录中的消息数决定。该关系大致是线性的,或者也许是超线性的。下表显示了这种关系。(您可以在此处看到我们的基准测试,以及一些图表。)

msgs history length  max GC pause (ms)
===================  =================
12500                                3
25000                                6
50000                               13
100000                              30
200000                              56
400000                             104
800000                             199
1600000                            487
3200000                           1957
6400000                           5378

我们已经对其他几个变量进行了试验,以发现它们是否可以减少延迟,但没有一个有很大的不同。这些不重要的变量包括:优化(-O-O2);RTS GC选项(-G-H-A-c),芯(的数目-N),不同的数据结构(Data.Sequence),消息的大小,并且产生短暂的垃圾的量。压倒一切的决定因素是历史记录中的邮件数量。

我们的工作原理是,消息数量的暂停是线性的,因为每个GC周期必须遍历所有可访问的内存并进行复制,这显然是线性操作。

问题:

  • 这个线性时间理论正确吗?可以用这种简单的方式来表示GC暂停的时间长度,还是现实更加复杂?
  • 如果GC暂停在工作内存中是线性的,是否有任何方法可以减少所涉及的恒定因子?
  • 是否有用于增量GC的选项或类似的选项?我们只能看到研究论文。我们非常愿意以吞吐量来降低延迟。
  • 除了拆分成多个进程外,是否有任何方法可以为较小的GC周期“划分”内存?

1
@Bakuriu:是的,但是在几乎没有任何调整的情况下,几乎所有现代操作系统都应该可以达到10毫秒。当我运行简化的C程序时,即使在旧的Raspberry pi上,它们也很容易实现5毫秒范围内的延迟,或者至少可靠地达到15毫秒左右的延迟。
leftaboutabout

3
您确定测试用例有用COntrol.Concurrent.Chan吗(例如您没有使用?可变对象会改变方程式)?我建议首先确保您知道正在生成的垃圾,并尽可能减少垃圾(例如,确保发生融合,请尝试-funbox-strict)。也许尝试使用流媒体库(iostream,管道,管道,流媒体),并performGC以更频繁的时间间隔直接调用。
jberryman '16

6
如果您要完成的工作可以在恒定的空间中完成,则从尝试实现这一点开始(例如,来自MutableByteArray;的环形缓冲区;在这种情况下,GC根本不涉及)
jberryman 2016年

1
对于那些建议使用可变结构并注意创建最少垃圾的人,请注意,保留的大小而不是所收集的垃圾量似乎决定了暂停时间。强制执行更频繁的收集会导致更多相同长度的停顿。编辑:可变的堆外结构可能很有趣,但是在很多情况下使用它并不是那么有趣!
mike 2016年

6
该描述无疑表明,GC的时间对于所有代来说都是线性的,重要的因素是保留对象的大小(用于复制)和存在于它们的指针数量(用于清除):ghc.haskell。 org / trac / ghc / wiki / Commentary / Rts / Storage / GC / Copying
mike 2016年

Answers:


96

实际上,拥有51ms的暂停时间以及200Mb的实时数据确实做得不错。我正在使用的系统的最大暂停时间更长,而实时数据量只有一半。

您的假设是正确的,主要的GC暂停时间与实时数据量成正比,不幸的是,GHC无法解决这个问题。我们过去曾尝试使用增量GC,但这是一个研究项目,尚未达到将其折叠成已发布的GHC所需的成熟度。

我们希望将来可以帮助解决这一问题的是紧凑的区域:https//phabricator.haskell.org/D1264。这是一种手动内存管理,您可以在堆中压缩结构,而GC不必遍历它。它最适合长寿命的数据,但也许足以在您的设置中用于单个消息。我们的目标是在GHC 8.2.0中使用它。

如果您处于分布式环境中,并且拥有某种负载均衡器,则可以使用一些技巧来避免遇到暂停问题,基本上可以确保负载均衡器不会将请求发送到即将做一个主要的GC,当然要确保即使没有收到请求,机器仍然可以完成GC。


13
嗨,西蒙,非常感谢您的详细答复!这是个坏消息,但结盟是好消息。目前,我们正朝着可变的实现方式发展,这是唯一合适的选择。一些我们不了解的事情:(1)负载平衡方案中包含哪些技巧-它们是否涉及手动performGC?(2)为什么用压缩会-c导致性能变差-我们猜想是因为压缩并没有发现很多可以保留的地方?(3)关于压坯还有更多细节吗?听起来很有趣,但不幸的是,对于我们来说,将来太遥远了。
jameshfisher


@AlfredoDiNapoli谢谢!
mljrg

9

我已经尝试过使用环形缓冲区方法IOVector作为基础数据结构来使用您的代码段。在我的系统(GHC 7.10.3,相同的编译选项)上,这导致最大时间(您在OP中提到的指标)减少了约22%。

注意 我在这里做了两个假设:

  1. 可变的数据结构可以很好地解决该问题(无论如何,我认为消息传递暗示着IO)
  2. 您的messageId是连续的

使用一些其他Int参数和算法(例如,将messageId重置为0或minBound)时,应该可以直接确定某个消息是否仍在历史记录中,并从环形缓冲区中的相应索引中检索它。

为了您的测试乐趣:

import qualified Control.Exception as Exception
import qualified Control.Monad as Monad
import qualified Data.ByteString as ByteString
import qualified Data.Map.Strict as Map

import qualified Data.Vector.Mutable as Vector

data Msg = Msg !Int !ByteString.ByteString

type Chan = Map.Map Int ByteString.ByteString

data Chan2 = Chan2
    { next          :: !Int
    , maxId         :: !Int
    , ringBuffer    :: !(Vector.IOVector ByteString.ByteString)
    }

chanSize :: Int
chanSize = 200000

message :: Int -> Msg
message n = Msg n (ByteString.replicate 1024 (fromIntegral n))


newChan2 :: IO Chan2
newChan2 = Chan2 0 0 <$> Vector.unsafeNew chanSize

pushMsg2 :: Chan2 -> Msg -> IO Chan2
pushMsg2 (Chan2 ix _ store) (Msg msgId msgContent) =
    let ix' = if ix == chanSize then 0 else ix + 1
    in Vector.unsafeWrite store ix' msgContent >> return (Chan2 ix' msgId store)

pushMsg :: Chan -> Msg -> IO Chan
pushMsg chan (Msg msgId msgContent) =
  Exception.evaluate $
    let
      inserted = Map.insert msgId msgContent chan
    in
      if chanSize < Map.size inserted
      then Map.deleteMin inserted
      else inserted

main, main1, main2 :: IO ()

main = main2

main1 = Monad.foldM_ pushMsg Map.empty (map message [1..1000000])

main2 = newChan2 >>= \c -> Monad.foldM_ pushMsg2 c (map message [1..1000000])

2
嗨!好答案。我怀疑这只能使速度提高22%的原因是因为GC仍然必须遍历IOVector每个索引处的和和(不可变,GC'd)值。我们目前正在研究使用可变结构重新实现的选项。它可能类似于您的环形缓冲区系统。但是我们将其完全移到Haskell内存空间之外,以执行我们自己的手动内存管理。
jameshfisher '16

11
@jamesfisher:我实际上也面临着类似的问题,但决定将内存管理保留在Haskell方面。解决方案的确是一个环形缓冲区,该缓冲区将原始数据的按字节复制在单个连续的内存块中,从而产生单个Haskell值。在此RingBuffer.hs要点中查看一下。我根据您的示例代码对其进行了测试,并且加速了关键指标约90%。随时在您方便时使用该代码。
mgmeier

8

我必须同意其他人的看法-如果您有严格的实时限制,那么使用GC语言是不理想的。

但是,您可能会考虑尝试其他可用的数据结构,而不仅仅是Data.Map。

我使用Data.Sequence重写了它,并得到了一些有希望的改进:

msgs history length  max GC pause (ms)
===================  =================
12500                              0.7
25000                              1.4
50000                              2.8
100000                             5.4
200000                            10.9
400000                            21.8
800000                            46
1600000                           87
3200000                          175
6400000                          350

即使您针对延迟进行了优化,我也注意到其他指标也有所改善。在200000情况下,执行时间从1.5s减少到0.2s,总内存使用量从600MB减少到27MB。

我应该注意,我通过调整设计来作弊:

  • 我删除了IntMsg,所以它不是在两个地方。
  • 而不是使用地图从Ints到ByteStringS,我用SequenceByteStringS,和一个不是Int每封邮件,我认为它可以用一个来完成Int整个Sequence。假设消息无法重新排序,则可以使用单个偏移量将所需的消息转换为它在队列中的位置。

(我包括一个附加功能getMsg来演示这一点。)

{-# LANGUAGE BangPatterns #-}

import qualified Control.Exception as Exception
import qualified Control.Monad as Monad
import qualified Data.ByteString as ByteString
import Data.Sequence as S

newtype Msg = Msg ByteString.ByteString

data Chan = Chan Int (Seq ByteString.ByteString)

message :: Int -> Msg
message n = Msg (ByteString.replicate 1024 (fromIntegral n))

maxSize :: Int
maxSize = 200000

pushMsg :: Chan -> Msg -> IO Chan
pushMsg (Chan !offset sq) (Msg msgContent) =
    Exception.evaluate $
        let newSize = 1 + S.length sq
            newSq = sq |> msgContent
        in
        if newSize <= maxSize
            then Chan offset newSq
            else
                case S.viewl newSq of
                    (_ :< newSq') -> Chan (offset+1) newSq'
                    S.EmptyL -> error "Can't happen"

getMsg :: Chan -> Int -> Maybe Msg
getMsg (Chan offset sq) i_ = getMsg' (i_ - offset)
    where
    getMsg' i
        | i < 0            = Nothing
        | i >= S.length sq = Nothing
        | otherwise        = Just (Msg (S.index sq i))

main :: IO ()
main = Monad.foldM_ pushMsg (Chan 0 S.empty) (map message [1..5 * maxSize])

4
嗨!感谢您的回答。您的结果肯定仍然显示出线性下降,但是,您获得如此高的加速速度非常有趣Data.Sequence-我们进行了测试,发现它实际上比Data.Map差!我不确定有什么区别,所以我将不得不调查...
jameshfisher

8

如其他答案所述,GHC中的垃圾收集器会遍历实时数据,这意味着您存储在内存中的寿命越长的数据越多,GC暂停将越长。

GHC 8.2

为部分解决此问题,GHC-8.2中引入了一种称为紧凑区域的功能。它既是GHC运行时系统的功能,又是提供方便使用的接口的库。紧凑区域功能允许将您的数据放入内存中的单独位置,并且在垃圾回收阶段GC不会遍历数据。因此,如果您要保留较大的结构,请考虑使用紧凑区域。但是,紧凑区域本身内部没有 迷你垃圾收集器,它对于仅追加数据结构更有效,而不是像HashMap您也希望删除内容的地方。虽然您可以克服此问题。有关详细信息,请参阅以下博客文章:

GHC 8.10

此外,自GHC-8.10起,实施了新的低延迟增量垃圾收集器算法。这是另一种GC算法,默认情况下未启用,但是您可以根据需要选择加入。因此,您可以将默认GC切换到较新的GC,以自动获取紧凑区域提供的功能,而无需进行手动包装和展开。但是,新的GC并不是灵丹妙药,不能自动解决所有问题,并且需要权衡取舍。有关新GC的基准,请参考以下GitHub存储库:


3

好吧,您发现了GC语言的局限性:它们不适合核心实时系统。

您有2个选择:

1st增加堆大小并使用2级缓存系统,将最旧的消息发送到磁盘,并将最新的消息保留在内存中,您可以使用OS分页来实现。尽管采用该解决方案,但问题在于,根据所使用的辅助存储单元的读取能力,分页可能会很昂贵。

第二程序使用“ C”对该解决方案进行编程,并将其与FFI连接到haskell。这样,您可以执行自己的内存管理。这将是最好的选择,因为您可以自己控制所需的内存。


1
嗨,费尔南多。谢谢你 我们的系统仅是“软”实时的,但是在我们的案例中,我们发现即使对于软实时,GC也过于苛刻。我们绝对倾向于您的#2解决方案。
jameshfisher
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.