在 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 复制的字节数,性能测试证实了对 foldPairProduct
和 foldCrossProduct
的“空间复杂度”的分析。
性能测试证明对foldPairProduct'
的分析出人意料地显示N1 * N2
甚至更糟的空间使用率。这可能是由于递归调用的评估效率低下。结果附在下面(ghc 设置与 Yuras 所做的相同)。
更新 2
当我从 cmets 和答案中学习时,更新了一些进一步的实验。对于foldPairProduct
,使用中的总内存与 Daniel Fischer 解释的空间复杂度一致。
对于 按照大牛的建议,交换了foldCrossProduct
,虽然 Daniel 的复杂性分析对我来说很有意义,但结果并没有显示出线性的内存使用情况。 x <- xs
和y <- 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 = 10000000
,foldPairProduct (+) [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.88s
到GC 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)
空间复杂度(xs
和ys
列表被多次使用,因此被重复使用)。
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
将(可能)在此处在不同的 x
s 之间共享,因此会产生 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 中即时减少列表的主要内容,如果未能解决你的问题,请参考以下文章