Haskell列表生成器高内存使用率

时间:2017-07-09 02:57:06

标签: list haskell memory

在处理竞争性编程问题时,我发现了一个有趣的问题,大大降低了我的一些代码的性能。经过多次实验,我设法将问题简化为以下最小的例子:

module Main where

main = interact handle

handle :: String -> String
-- handle s = show $ sum l
-- handle s = show $ length l
-- handle s = show $ seq (length l) (sum l)
  where
    l = [0..10^8] :: [Int]

如果您单独取消注释每个注释行,使用ghc -O2 test.hs进行编译并使用time ./test > /dev/null运行,您应该得到以下内容:

sum l

0.02user 0.00system 0:00.03elapsed 93%CPU (0avgtext+0avgdata 3380maxresident)k
0inputs+0outputs (0major+165minor)pagefaults 0swaps

length l

0.02user 0.00system 0:00.02elapsed 100%CPU (0avgtext+0avgdata 3256maxresident)k
0inputs+0outputs (0major+161minor)pagefaults 0swaps

seq (length l) (sum l)

5.47user 1.15system 0:06.63elapsed 99%CPU (0avgtext+0avgdata 7949048maxresident)k
0inputs+0outputs (0major+1986697minor)pagefaults 0swaps

看看峰值内存使用量的大幅增加。这有点意义,因为总和和长度当然可以懒惰地将列表作为流使用,而seq将触发整个列表的评估,然后必须存储。但代码的seq版本仅使用8 GB的内存来处理仅包含400 MB实际数据的列表。 Haskell列表的纯函数性质可以解释一些小的常数因子,但内存增加20倍似乎是无意识的。

这种行为可以通过许多方式触发。也许最简单的方法是使用force中的Control.DeepSeq,但我最初遇到此问题的方式是使用Data.Array.IArray(我只能使用标准库)并尝试构建数组从列表中。 Array的实现是monadic,因此强制评估正在构建它的列表。

如果有人对此行为的根本原因有任何了解,我会非常有兴趣了解为什么会发生这种情况。我当然也赞赏有关如何避免这个问题的任何建议,请记住在这种情况下我必须只使用标准库,并且每个Array构造函数都会占用并最终强制列表。

我希望你发现这个问题和我一样有趣,但希望不要那么莫名其妙。

编辑: user2407038的评论让我意识到我忘了发布分析结果。我试过分析这段代码,而分析器只是说明100%的分配是在handle.l中执行的,所以看起来只是强制评估列表的任何内容都会占用大量的内存。如上所述,使用force中的Control.DeepSeq函数,构造Array或强制列表的任何其他内容都会导致此行为。我很困惑为什么它需要8 GB的内存来计算包含400 MB数据的列表。即使列表中的每个元素都需要两个64位指针,这仍然只是5的因素,我认为GHC能够做出比这更有效的事情。如果不是这是Array包的明显瓶颈,因为构造任何数组本身就要求我们分配比数组本身多得多的内存。

所以,最终:有没有人知道为什么强制列表需要如此大量的内存,这在性能上有如此高的成本?

编辑: user2407038提供了指向非常有用的GHC Memory Footprint参考的链接。这完全解释了所有内容的数据大小,几乎完全解释了巨大的开销:指定[Int]需要5N + 1个字的内存,每个字8个字节,每个元素提供40个字节。在此示例中,建议使用4 GB,占总峰值使用量的一半。很容易相信总和的评估会增加一个类似的因素,所以这回答了我的问题。

感谢所有评论者的帮助。

编辑:正如我上面提到的,我最初遇到了这种行为,为什么要尝试构建Array。稍微深入了解GHC.Arr我在构造数组时发现了我认为这种行为的根本原因:构造函数折叠列表以组成ST monad中的程序然后运行它。显然ST在完全组合之前不能被执行,并且在这种情况下,ST结构将是大的并且在输入的大小上是线性的。为了避免这种行为,我们必须以某种方式修改构造函数,以便在ST中添加它们时从列表中流式传输元素。

1 个答案:

答案 0 :(得分:1)

这里有多种因素可以发挥作用。第一个是GHC将懒惰地从l中抬起handle。这将使handle能够重用l,这样您就不必每次都重新计算它,但在这种情况下会造成空间泄漏。如果-ddump-simpl ified core:

,您可以查看此信息
Main.handle_l :: [Int]                                                   
[GblId,                                                                  
 Str=DmdType,                                                            
 Unf=Unf{Src=<vanilla>, TopLvl=True, Value=False, ConLike=False,         
         WorkFree=False, Expandable=False, Guidance=IF_ARGS [] 40 0}]    
Main.handle_l =                                                          
  case Main.handle3 of _ [Occ=Dead] { GHC.Types.I# y_a1HY ->             
  GHC.Enum.eftInt 0 y_a1HY                                               
  }

计算[0..10^7] 1 的功能隐藏在其他函数中,但基本上是handle_l = [0..10^7],位于顶层({ {1}})。它不会被回收,因为您可能会或可能不会再次使用TopLvl=True。如果我们使用handle,则handle s = show $ length l本身将被内联。您找不到任何类型为l的{​​{1}}函数。

因此GHC检测到您使用TopLvl=True两次并创建顶级CAF。 CAF有多大? [Int]需要两个字:

l

一个用于Int,一个用于data Int = I# Int# I#多少钱?

Int#

这是[Int]的一个词,data [a] = [] | (:) a ([a]) -- pseudo, but similar 的三个词。因此,大小为N的[]列表的总大小为(3N + 1)+ 2N个单词,在您的情况下为5N + 1个单词。鉴于你的记忆,我假设你的平台上有一个字是8字节,所以我们最终得到了

(:) a ([a])

那么我们如何摆脱这个名单呢?我们的第一个选择是摆脱[Int]

5 * 10^8 * 8 bytes = 4 000 000 000 bytes

由于l规则,现在将在常量内存中运行。虽然我们有handle _ = show $ seq (length [0..10^8]) (sum [0..10^8]) 两次,但它们没有相同的名称。如果我们检查foldr/buildr tats,我们会看到它在恒定的内存中运行:

[0..10^8]

但这并不是很好,因为我们现在必须跟踪-s的所有用途。如果我们创建一个函数会怎么样?

> SO.exe +RTS -s                                                                
5000000050000000   4,800,066,848 bytes allocated in the heap                    
         159,312 bytes copied during GC                                         
          43,832 bytes maximum residency (2 sample(s))                          
          20,576 bytes maximum slop                                             
               1 MB total memory in use (0 MB lost due to fragmentation)        

                                     Tot time (elapsed)  Avg pause  Max pause   
  Gen  0      9154 colls,     0 par    0.031s   0.013s     0.0000s    0.0000s   
  Gen  1         2 colls,     0 par    0.000s   0.000s     0.0001s    0.0002s   

  INIT    time    0.000s  (  0.000s elapsed)                                    
  MUT     time    4.188s  (  4.232s elapsed)                                    
  GC      time    0.031s  (  0.013s elapsed)                                    
  EXIT    time    0.000s  (  0.001s elapsed)                                    
  Total   time    4.219s  (  4.247s elapsed)                                    

  %GC     time       0.7%  (0.3% elapsed)                                       

  Alloc rate    1,146,284,620 bytes per MUT second                              

  Productivity  99.3% of total user, 98.6% of total elapsed

这有效,但我们必须内联[0..10^8],否则如果我们使用优化,我们会遇到与以前相同的问题。 handle :: String -> String handle _ = show $ seq (length $ l ()) (sum $ l ()) where {-# INLINE l #-} l _ = [0..10^7] :: [Int] (以及l)启用-O1,它与常见的子表达式消除一起将-O2提升到顶部。所以我们要么需要内联它,要么使用-ffull-laziness来阻止这种行为。

1 必须减少列表大小,否则我会开始交换。