问题描述
我试图从有时在GHC(8.4.3)中进行的优化中受益,在该优化中,将大量数据的“构建”放入PINNED内存中。 (我在这里可能不掌握所有正确的术语)。这是一个简单的示例:
Pinned1.hs:
main = print $ sum $ tail ([1..100000000] :: [Int])
然后:
ghc -O2 Pinned1.hs -prof -rtsopts
Pinned1 +RTS -hc -p -xt
hp2ps -e8in -c Pinned1.hp
显示〜40K固定,几乎没有堆栈使用,Pinned1 +RTS -hd -p -xt
显示〜40K是ARR_WORDS。
Pinned1.prof显示:
total time = 2.14 secs (2137 ticks @ 1000 us,1 processor)
total alloc = 8,000,046,088 bytes (excludes profiling overheads)
看过-sdump-simpl,我可以看到导致这种情况的那种代码。这是一个稍微复杂些的示例,它从Core反向翻译为Haskell代码,发生相同的事情:
Pinned2.hs:
main = print $ sum $ snd $ wgoC 1 0
wgoC :: Int -> Int -> (Int,[Int])
wgoC n finalState =
let (nxt,ys') = case n of 100000000 -> (finalState,[])
_ -> wgoC (n+1) finalState
in (n,n + nxt * 9: ys')
wgoC将下一个n返回,该n用于计算列表中的值。它报告大约40K PINNED / ARR_WORDS内存,几乎没有堆栈,并且此配置文件输出:
total time = 5.50 secs (5500 ticks @ 1000 us,1 processor)
total alloc = 16,800,112 bytes (excludes profiling overheads)
但是,这:
Pinned3.hs:
main = print $ sum $ snd $ wgoD 1 0
wgoD :: Int -> Int -> (Int,[Int])
wgoD n finalState =
let (ttl',[])
_ -> wgoD (n+1) finalState
in (ttl' + n,n + (ttl' + n) * 9 : ys')
2分钟后无法完成。它的确仅具有1000000的值,而我看不到PINNED内存和STACK的使用情况(〜100M)。 (我认为正是STACK用法使它以某种方式运行得慢得多)。
我在Pinned2和Pinned3之间看到的主要区别是Pinned3在返回状态中包含来自递归调用的信息(返回对的fst:后续值的累加总和),但是Pinned2仅包含wgoC的参数。
所以我的问题是:
Q1)使用PINNED内存的决定在哪里发生(在compiler pipeline中)? -ddump-simpl和-ddump-cmm都没有明显区别(尽管有点复杂,所以也许我错过了一些东西)。
Q2)PINNED / STACK决策基于什么? (我可以找到的唯一对PINNED的引用,例如this,说它对FFI调用很有用,但似乎也为“优化”采用了它。)
Q3)是否可以通过某种方式修改Pinned3,使其使用PINNED?
Q4)(作为最后的手段)Pinned3是否还有其他一些调整,以便有足够的堆栈空间,并且可以在合理的时间内运行? (天真的,我希望它的性能与Pinned2相似。)
[请注意,我只是在这里试图理解PINNED / STACK机制。我敢肯定还有其他方法可以编写Pinned3,因此它可以很好地融合并且几乎不需要任何内存,但是这个问题并不关乎。]
谢谢!
解决方法
固定内存在这里不起作用。
该程序:
main = print $ sum $ tail ([1..100000000] :: [Int])
不直接使用任何固定内存 。您看到的固定内存来自运行时系统本身的初始化。固定内存由GHC的字节数组基元分配;在用户代码中,当您使用Data.Text
或Data.ByteString
两者都使用字节数组进行内部实现时,最有可能看到固定的内存使用情况。对于该程序,我将猜测用于stdin和stdout的I / O缓冲区是固定的,但也许还有其他东西。无论如何,Int
的列表不会固定任何内容。
(几乎)所有Haskell程序一样,Pinned1.hs
使用大量的堆和大量的堆栈(每个千兆字节),但至关重要的是,释放它的速度与分配的速度一样快(或者如果您更喜欢在谈论堆栈,将其“弹出”与“推动”一样快。 Pinned2.hs
也是如此。这些程序运行正常。
Pinned3.hs
的问题不是它使用堆栈而不是固定内存,而是使用了比Pinned1
和Pinned2
更多的堆栈,并且无法像弹出一样快地弹出它推动它,所以堆栈堆积。
那么,为什么Pinned3
会堆积堆栈?
一般来说,如果递归调用的结果的某些部分是函数应用程序的目标,则堆栈会在递归调用中累积,当它返回 AND 时,评估结果的这一部分本身需要另一个递归呼叫。考虑一下程序:
eatStack 100000000 = 1
eatStack n = 1 + eatStack (n + 1)
main = print $ eatStack 1
其中,编译并运行:
stack ghc -- -O2 -prof -rtsopts EatStack.hs
./EatStack +RTS -hd -p -xt
stack exec hp2ps -- -e8in -c EatStack.hp
产生通常的金字塔形堆栈堆积(峰值为1.4G左右)。这里的问题是,递归eatStack (n+1)
的返回值在返回时受函数应用程序\x -> 1 + x
的约束,并且计算结果本身需要进一步递归。也就是说,计算eatStack 0
要求在调用\x -> 1 + x
之前将eatStack 1
压入堆栈,该操作只能在调用\x -> 1 + x
之前将eatStack 2
压入堆栈后返回其结果,等等。结果就是堆栈堆积。
值得注意的是,构造函数应用程序的处理方式有所不同。以下程序:
noStack 100000000 = []
noStack n = 1 : noStack (n + 1)
main = print $ last (noStack 1)
将部分应用的构造函数(:) 1
应用于递归结果noStack (n+1)
,不使用栈。 (它似乎使用了40k的固定,但实际上又是运行时系统。EatStack
也使用了40k的固定。)在某些情况下(不在此处),这样的构造函数应用程序可能会导致堆堆积,但实际上并没有通常不会累积堆栈。
对于您的Pinned2
和Pinned3
示例,正在发生类似的事情,尽管显然要复杂一些。首先让我们看一下Pinned2
,然后考虑评估wgoC 1 0
。匹配大小写并替换参数,评估等效于:
wgoC 1 0 =
let (nxt,ys') = wgoC 2 0
in (1,1 + nxt * 9 : ys')
当sum . snd
要求列表的第一个元素,即thunk 1 + nxt * 9
时,这将强制nxt
通过递归调用进行求值。因为此返回值受函数应用程序(即\x -> 1 + x * 9
)的约束,所以使用了一些堆栈,但是对递归调用进行了评估:
wgoC 2 0 =
let (nxt,ys') = wgoC 3 0
in (2,2 + nxt * 9 : ys')
立即为nxt
调用中的本地绑定wgoC 1 0
生成所需的值,即返回的元组fst (wgoC 2 0) = 2
的第一个元素,而无需进一步递归。因此,我们取值2,弹出延续\x -> 1 + x * 9
,然后将值传递给延续产生1 + 2 * 9 = 19
。这给出了列表的第一个元素,没有净栈使用量。列表的其余部分,即ys'
调用中的本地绑定wgoC 1 0
,仍然处于2 + nxt * 9 : ys'
调用的封闭状态wgoC 2 0
中。
当需要下一个元素时,我们需要一些堆栈以将递归\x -> 2 + x * 9
应用于递归nxt
中的结果(nxt,ys') = wgoC 3 0
,但这将以相同的方式求值,立即返回nxt = 3
和一个ys'
的重击,因此延续\x -> 2 + x * 9
将从堆栈中弹出并应用于nxt = 3
,而无需进一步递归,产生2 + 3 * 9 = 29
和一个3 + nxt * 9 : ys'
被wgoC 3 0
调用关闭的笨拙的Pinned3
。
每个元素都可以被强制使用而不使用净堆栈。我们推送一个延续,然后立即弹出它,并将其应用于不需要本身的递归调用的返回值的一部分,而无需进一步递归。结果是没有净堆栈堆积。
现在,考虑使用wgoD 1 0
和呼叫wgoD 1 0 =
let (ttl',ys') = wgoD 2 0
in (ttl' + 1,1 + (ttl' + 1) * 9 : ys')
:
sum . snd
当1 + (ttl' + 1) * 9
要求列表的第一个元素,即thunk ttl'
时,这将强制\x -> 1 + (ttl' + 1) * 9
通过递归调用进行求值。因为有一个待处理的函数应用程序wgoD 2 0 =
let (ttl',ys') = wgoD 3 0
in (ttl' + 2,2 + (ttl' + 2) * 9 : ys')
,所以将使用一些堆栈。递归调用:
ttl'
只能通过评估返回元组wgoC 1 0
的第一部分来为ttl' + 2
调用中的本地绑定ttl'
提供所需的值,但这需要强制wgoD 3 0
通过递归ttl'
调用。由于\x -> x + 2
会在返回时受函数应用程序wgoD 3 0 =
let (ttl',ys') = wgoD 4 0
in (ttl' + 3,3 + (ttl' + 3) * 9 : ys')
的约束,因此我们要多推一些堆栈并继续求值:
ttl'
要获得在wgoD 2 0
调用中本地绑定的必需wgoD 3 0
,我们需要评估ttl' + 3
中返回元组的第一个组成部分,即\x -> x + 3
。这是一个函数应用程序ttl'
,我们将其压入堆栈,应用于递归调用wgoD 4 0
返回的Pinned3
。
因此,\x -> x + 2
将一系列连续的\x -> x + 3
,\x -> x + 4
,wgoD 2 0
'等推入堆栈,所有这些都是为了评估的第一个成分wgoD 100000000 0
返回的元组,直到到达finalState = 0
才没有机会弹出任何东西,然后,如果有足够的堆栈,它最终会得到一个数字ttl' + n
作为第一个元组组件达到目标然后,在应用延续时,所有堆栈都将弹出,我们将拥有列表的第一个元素!
一旦通过,情况就不会那么糟了。至此,所有表达式n + (ttl' + n) * 9
均已求值,并且可以在计算表达式Pinned3
中重用它们,因此可以相对快速地生成其余元素-因为必须保留其值在某个地方-您还将以与堆栈使用大致相同的速率累积堆使用。
您可以将100000000换成类似10000000(七个零)的内容,并且在合理的时间内运行,并显示金字塔形状的堆栈堆积。它在1.4演出左右达到峰值,然后又回落到零。
在保持{{1}}的算法结构不变的同时,我看不到任何真正简单的“修复”方法。