在 Haskell 中即时减少列表

Posted

技术标签:

【中文标题】在 Haskell 中即时减少列表【英文标题】:Reduce list on the fly in Haskell 【发布时间】:2013-02-06 04:09:57 【问题描述】:

假设我有一个函数f,它接受一些输入并产生一个数字。在函数f 中,根据输入创建一个列表,然后将其减少(例如使用foldl' g)以产生最终输出数字。因为中间列表毕竟是要归约的,是否可以应用reduce函数g不表达中间列表。这里的目标是限制用于存储(或表达,如果“存储”一个不太准确的词)列表的内存。

为了说明这一点,这个函数foldPairProduct 为中间列表占用了O(N1 * N2) 空间(由于表达式和惰性求值,占用的空间可能更复杂,但我认为它是成比例的或更糟)。这里N1, N2是两个输入列表的大小。

foldPairProduct :: (Num a, Ord a)  => (a -> a -> a) -> [a] -> [a] -> a
foldPairProduct f xs ys = foldl1 f [ x*y | x <- xs, y <- ys]

该逻辑的替代实现是 foldPairProduct',它占用O(2 * 2) 空间。

foldPairProduct' :: Num a => (Maybe a -> Maybe a -> Maybe a) -> [a] -> [a] -> Maybe a  
foldPairProduct' _ _ [] = Nothing
foldPairProduct' _ [] _ = Nothing
foldPairProduct' f (x:xs) (y:ys) = 
  foldl1 f [Just $ x*y, foldPairProduct' f [x] ys, foldPairProduct' f xs [y], 
            foldPairProduct' f xs ys]

foldCrossProduct 的情况更加严重,它的实现类似于foldPairProduct,只是它接受多个列表作为输入。中间列表的空间复杂度(仍然是命令式语言的意义上)是O(N1 * N2 * ...* Nk),其中k[[a]] 的长度。

foldCrossProduct :: Num a => (a -> a -> a) -> [[a]]  -> a
foldCrossProduct f xss = foldl1 f (crossProduct xss)

crossProduct :: Num a => [[a]] -> [a]
crossProduct [] = []
crossProduct (xs:[]) = xs
crossProduct (xs:xss) = [x * y | x <- xs, y <- crossProduct xss] 

如果我们按照foldPairProduct'的实现思路,空间复杂度将是k^2,空间效率更高。我的问题是:

    我为一对列表实现了foldPairProduct'。但是,似乎为任意数量的列表实现它并不简单。

    我的意思不是将 Haskell 与命令式语言进行比较,但是否有使用常量空间的实现(或者换句话说,不表达上述长度的中间列表)?也许 Monad 会有所帮助,但我对它很陌生。

    编译器真的发挥了它的魔力吗?也就是说,它注意到列表是中间的并且要减少,并且确实找到了一种节省空间的方法来评估它。毕竟,我认为惰性求值和编译器优化就是为此而设计的。

    欢迎提出任何意见。谢谢。

更新 1

基于改变输入大小 N1, N2, N3 和观察 GC 复制的字节数,性能测试证实了对 foldPairProductfoldCrossProduct 的“空间复杂度”的分析。

性能测试证明对foldPairProduct' 的分析出人意料地显示N1 * N2 甚至更糟的空间使用率。这可能是由于递归调用的评估效率低下。结果附在下面(ghc 设置与 Yuras 所做的相同)。

更新 2

当我从 cmets 和答案中学习时,更新了一些进一步的实验。对于foldPairProduct使用中的总内存与 Daniel Fischer 解释的空间复杂度一致。

对于foldCrossProduct,虽然 Daniel 的复杂性分析对我来说很有意义,但结果并没有显示出线性的内存使用情况。 按照大牛的建议,交换了x &lt;- xsy &lt;- crossproduct ys,确实实现了线性空间复杂度。

对于foldCrossProduct (max) [[1..100],[1..n], [1..1000]],n = 100、1000、10000、100000,使用的内存为 2、2、3、14 MB。

foldPairProduct [1..n] [1..10000]

n = 100
  120,883,320 bytes allocated in the heap 
   56,867,728 bytes copied during GC
      428,384 bytes maximum residency (50 sample(s)) 
       98,664 bytes maximum slop
            3 MB total memory in use (0 MB lost due to fragmentation)     

n = 1000
 1,200,999,280 bytes allocated in the heap 
   569,837,360 bytes copied during GC   
       428,384 bytes maximum residency (500 sample(s))
        99,744 bytes maximum slop 
             3 MB total memory in use (0 MB lost due to fragmentation) 
n = 10000

  12,002,152,040 bytes allocated in the heap
   5,699,468,024 bytes copied during GC 
         428,384 bytes maximum residency (5000 sample(s))
          99,928 bytes maximum slop 
               3 MB total memory in use (0 MB lost due to fragmentation)

n = 100000

 120,013,672,800 bytes allocated in the heap 
  56,997,625,608 bytes copied during GC 
         428,384 bytes maximum residency (50000 sample(s)) 
          99,984 bytes maximum slop 
               3 MB total memory in use (0 MB lost due to fragmentation) 

foldPairProduct [1..10000] [1..n]

n = 100

     121,438,536 bytes allocated in the heap 
          55,920 bytes copied during GC     
          32,408 bytes maximum residency (1 sample(s)) 
          19,856 bytes maximum slop  
               1 MB total memory in use (0 MB lost due to fragmentation)

n = 1000

   1,201,511,296 bytes allocated in the heap 
         491,864 bytes copied during GC     
          68,392 bytes maximum residency (1 sample(s)) 
          20,696 bytes maximum slop                   
               1 MB total memory in use (0 MB lost due to fragmentation)

n = 10000

  12,002,232,056 bytes allocated in the heap 
   5,712,004,584 bytes copied during GC     
         428,408 bytes maximum residency (5000 sample(s)) 
          98,688 bytes maximum slop 
               3 MB total memory in use (0 MB lost due to fragmentation)

n = 100000

 120,009,432,816 bytes allocated in the heap
  81,694,557,064 bytes copied during GC 
       4,028,408 bytes maximum residency (10002 sample(s))
         769,720 bytes maximum slop 
              14 MB total memory in use (0 MB lost due to fragmentation) 

foldPairProduct [1..n] [1..n]

n = 100
 1,284,024 bytes allocated in the heap
    15,440 bytes copied during GC
    32,336 bytes maximum residency (1 sample(s))
    19,920 bytes maximum slop                  
         1 MB total memory in use (0 MB lost due to fragmentation)  

n = 1000
 120,207,224 bytes allocated in the heap  
     114,848 bytes copied during GC 
      68,336 bytes maximum residency (1 sample(s)) 
      24,832 bytes maximum slop 
           1 MB total memory in use (0 MB lost due to fragmentation)  

n = 10000

  12,001,432,024 bytes allocated in the heap 
   5,708,472,592 bytes copied during GC 
         428,336 bytes maximum residency (5000 sample(s)) 
          99,960 bytes maximum slop 
               3 MB total memory in use (0 MB lost due to fragmentation) 

n = 100000
 1,200,013,672,824 bytes allocated in the heap 
   816,574,713,664 bytes copied during GC 
         4,028,336 bytes maximum residency (100002 sample(s)) 
           770,264 bytes maximum slop 
                14 MB total memory in use (0 MB lost due to fragmentation) 

foldCrossProduct (max) [[1..n], [1..100], [1..1000]]

n = 100
     105,131,320 bytes allocated in the heap 
      38,697,432 bytes copied during GC     
         427,832 bytes maximum residency (34 sample(s)) 
         209,312 bytes maximum slop 
               3 MB total memory in use (0 MB lost due to fragmentation)

n = 1000
   1,041,254,480 bytes allocated in the heap 
     374,148,224 bytes copied during GC 
         427,832 bytes maximum residency (334 sample(s))
         211,936 bytes maximum slop 
               3 MB total memory in use (0 MB lost due to fragmentation)

n = 10000
  10,402,479,240 bytes allocated in the heap 
   3,728,429,728 bytes copied during GC     
         427,832 bytes maximum residency (3334 sample(s))
         215,936 bytes maximum slop
               3 MB total memory in use (0 MB lost due to fragmentation)

foldCrossProduct (max) [[1..100], [1..n], [1..1000]]

n = 100
     105,131,344 bytes allocated in the heap 
      38,686,648 bytes copied during GC  
         431,408 bytes maximum residency (34 sample(s)) 
         205,456 bytes maximum slop 
               3 MB total memory in use (0 MB lost due to fragmentation)

n = 1000
   1,050,614,504 bytes allocated in the heap
     412,084,688 bytes copied during GC 
       4,031,456 bytes maximum residency (53 sample(s)) 
       1,403,976 bytes maximum slop
              15 MB total memory in use (0 MB lost due to fragmentation)    
n = 10000
    quit after over 1362 MB total memory in use (0 MB lost due to fragmentation)    

foldPairProduct' [1..n] [1..n]

n = 100
 4,351,176 bytes allocated in the heap
    59,432 bytes copied during GC    
    74,296 bytes maximum residency (1 sample(s))
    21,320 bytes maximum slop                  
         1 MB total memory in use (0 MB lost due to fragmentation)

n = 1000
 527,009,960 bytes allocated in the heap 
  45,827,176 bytes copied during GC 
     211,680 bytes maximum residency (1 sample(s)) 
      25,760 bytes maximum slop 
           2 MB total memory in use (0 MB lost due to fragmentation)

【问题讨论】:

我相信haskell的懒惰的目的是即使将某些东西表示为列表,如果不需要,也不一定存储列表。 你真的测试过空间消耗吗? "观察 GC 复制的字节数" 为什么如此关注 GC 期间复制的字节数?字节最大驻留或使用中的总内存应该更准确地测量空间使用情况。您的程序使用不可变数据运行,因此可以进行大量分配。可能是GC复制很多的问题,但和内存使用无关。 我发现这个paper 是关于列表亚型的融合,这可能具有理论意义。 复制的字节不是那么有趣。打开更多统计信息并检查正在使用的最大堆是否增长。这是需要担心的数字。 【参考方案1】:

对列表的创建/修改/使用进行了特定优化,称为loop fusion。因为 Haskell 是纯粹且不严格的,所以有很多像 map f . mag g == map (f . g) 这样的定律。

如果编译器由于某种原因无法识别代码并生成次优代码(在传递 -O 标志之后),我会详细研究流融合,看看是什么阻止了它。

【讨论】:

【参考方案2】:

(好吧,我错了,它在恒定空间中不起作用,因为其中一个列表被多次使用,所以它很可能具有线性空间复杂度)

您是否尝试编译启用优化的测试程序?您的 foldPairProduct 对我来说看起来不错,我希望它能够在恒定空间中工作。

添加: 是的,它可以在恒定空间中工作(使用的总内存为 3 MB):

shum@shum-laptop:/tmp/shum$ cat test.hs 

foldPairProduct f xs ys = foldl1 f [ x*y | x <- xs, y <- ys]

n :: Int
n = 10000

main = print $ foldPairProduct (+) [1..n] [1..n]
shum@shum-laptop:/tmp/shum$ ghc --make -fforce-recomp -O test.hs 
[1 of 1] Compiling Main             ( test.hs, test.o )
Linking test ...
shum@shum-laptop:/tmp/shum$ time ./test +RTS -s
2500500025000000
  10,401,332,232 bytes allocated in the heap
   3,717,333,376 bytes copied during GC
         428,280 bytes maximum residency (3335 sample(s))
         219,792 bytes maximum slop
               3 MB total memory in use (0 MB lost due to fragmentation)

                                    Tot time (elapsed)  Avg pause  Max pause
  Gen  0     16699 colls,     0 par    4.27s    4.40s     0.0003s    0.0009s
  Gen  1      3335 colls,     0 par    1.52s    1.52s     0.0005s    0.0012s

  INIT    time    0.00s  (  0.00s elapsed)
  MUT     time    2.23s  (  2.17s elapsed)
  GC      time    5.79s  (  5.91s elapsed)
  EXIT    time    0.00s  (  0.00s elapsed)
  Total   time    8.02s  (  8.08s elapsed)

  %GC     time      72.2%  (73.2% elapsed)

  Alloc rate    4,659,775,665 bytes per MUT second

  Productivity  27.8% of total user, 27.6% of total elapsed


real    0m8.085s
user    0m8.025s
sys 0m0.040s
shum@shum-laptop:/tmp/shum$

【讨论】:

具体来说,它多次使用第二个参数。对于n = 10000000foldPairProduct (+) [1..10] [1..n] 使用 1362MB 内存,而foldPairProduct (+) [1..n] [1..10] 使用 1MB 内存。 @sabauma 我想知道是否可以完全阻止共享 我不知道有什么简单的方法可以做到这一点。 thunk duplication 上的这个问题可能很有趣。也就是说,这样做会涉及每次都重新计算列表,因此您可能会在时间与空间之间进行权衡。【参考方案3】:
foldPairProduct :: (Num a, Ord a)  => (a -> a -> a) -> [a] -> [a] -> a
foldPairProduct f xs ys = foldl1 f [ x*y | x <- xs, y <- ys]

可以成为一个好的记忆公民。第二个参数 ys 被重复使用,因此在计算过程中必须完全在内存中,但是中间列表在消耗时是延迟生成的,因此只贡献了恒定数量的内存,给出了一个整体 @ 987654323@ 空间复杂度。当然必须有length xs * length ys 列表单元产生和消耗,所以总体分配是O(length xs * length ys) [假设每个a 值使用有界空间]。通过提供更大的分配区域,可以大大减少在 GC 期间复制的字节数(以及因此 GC 所需的时间),+RTS -A1M,这个数字会下降

3,717,333,376 bytes copied during GC

默认设置为

20,445,728 bytes copied during GC

以及xs == ys = [1 .. 10000] :: [Int]f = (+)GC time 4.88sGC time 0.07s 的时间。

但这取决于严格度分析器的工作 - 如果它使用的类型是例如,它会很好。 Int 在编译时已知,已知组合函数是严格的。如果代码不是专门的,或者如果组合函数不知道是严格的,则折叠将产生O(length xs * length ys) 大小的 thunk。这个问题可以通过使用更严格的foldl1' 来缓解。

foldPairProduct' :: Num a => (Maybe a -> Maybe a -> Maybe a) -> [a] -> [a] -> Maybe a  
foldPairProduct' _ _ [] = Nothing
foldPairProduct' _ [] _ = Nothing
foldPairProduct' f (x:xs) (y:ys) = 
  foldl1 f [Just $ x*y, foldPairProduct' f [x] ys, foldPairProduct' f xs [y], 
            foldPairProduct' f xs ys]

直接遇到了严格性不足的问题,这里编译器无法对Just构造函数包裹的值进行严格处理,因为整体结果可能不需要它,所以折叠经常会产生在Just 下有一个O(length xs * length ys) 大小的thunk - 当然,对于某些f,如const,它会表现得很好。如果所有值都被使用,那么要成为一个良好的内存公民,您必须使用足够严格的组合函数f,同时在结果中强制Just 下的值(如果它是Just);使用foldl1' 也有帮助。这样,它可以具有O(length ys + length xs) 空间复杂度(xsys 列表被多次使用,因此被重复使用)。

foldCrossProduct :: Num a => (a -> a -> a) -> [[a]]  -> a
foldCrossProduct f xss = foldl1 f (crossProduct xss)

crossProduct :: Num a => [[a]] -> [a]
crossProduct [] = []
crossProduct (xs:[]) = xs
crossProduct (xs:xss) = [x * y | x <- xs, y <- crossProduct xss]

尽管 GHC 几乎不做 CSE(通用子表达式消除),但列表 crossProduct xss 将(可能)在此处在不同的 xs 之间共享,因此会产生 O(N2*...*Nk) 空间复杂度。如果列表中元素的顺序无关紧要,则重新排序为

crossProduct (xs:xss) = [x * y | y <- crossProduct xss, x <- xs]

帮助。那么crossProduct xss不需要一次在内存中,所以可以增量生产和消费,只有xs必须记住,因为它被多次使用。对于递归调用,必须共享剩余列表中的第一个,这样会产生整体O(N1+...+Nk-1) 空间复杂度。

【讨论】:

我现在开始理解和欣赏懒惰的评估。它与 Strictness 的结合是强大的:高级编程与低级语言的性能。如其他答案中所述,GHC 在此代码上的行为是否称为“流/循环融合”和“森林砍伐”? 我想知道允许足够严格的规则。因为,我经常会使用自定义类型和函数。例如在 foldCrossProduct 中,f 是自定义的 max 函数,因此它可以返回最大值和对应于最大值的索引向量。 不,这仍然是普通的懒惰评估。然后流融合将消除为中间列表分配列表单元。普通的惰性求值会产生一个单元格,消费它,产生下一个,消费......并且融合将让消费者直接获取列表元素,而不是获取包含指向该元素的指针的单元格。适当严格性的规则相当困难,因为正确的严格性因情况而异。过于严格 - 或在错误的地方过于严格 - 对速度和内存消耗的影响与过多...... 懒惰(或在错误的地方懒惰)。根据经验,如果结果可以增量生成,则需要惰性,如果结果只能在完成后返回,则需要严格。然而,也有例外。对于您的示例,您当然希望 f strict 的最大值,对于索引,它不是那么明确,但是如果它们被懒惰地处理,我会看到更多可能的陷阱。 懒惰的评价,一把双刃剑?

以上是关于在 Haskell 中即时减少列表的主要内容,如果未能解决你的问题,请参考以下文章

在 Haskell 中交错列表列表

如何在 haskell 中打印列表?

在 Haskell 中合并两个列表

在 Haskell 中整理列表理解

列表中最短的列表(Haskell)

在 Haskell 中添加列表/覆盖现有列表的功能