始终保证`seq`的评估顺序(另外还有'pseq`的奇怪行为)

时间:2018-01-23 20:28:04

标签: haskell lazy-evaluation ghc operator-precedence

seq函数的文档说明如下:

  

关于评估顺序的说明:表达式seq a b并不保证在a之前评估bseq给出的唯一保证是ab将在seq返回值之前进行评估。特别是,这意味着可以在b之前评估a。如果您需要保证特定的评估顺序,则必须使用" parallel"中的函数pseq。封装

所以我有一个惰性版本的sum函数with accumulator:

sum :: Num a => [a] -> a
sum = go 0
  where
    go acc []     = acc
    go acc (x:xs) = go (x + acc) xs

显然,这在大名单上非常慢。现在我使用seq

重写此功能
sum :: Num a => [a] -> a
sum = go 0
  where
    go acc []     = acc
    go acc (x:xs) = let acc' = x + acc
                    in acc' `seq` go acc' xs

我看到了巨大的性能提升!但我想知道它有多可靠?我是靠运气得到的吗?因为GHC可以首先评估递归调用(根据文档)并且仍然累积thunk。看起来我需要使用pseq来确保在递归调用之前始终评估acc'。但是对于pseq,我认为与seq版本相比,性能会下降。机器上的数字(用于计算sum [1 .. 10^7]

  • 天真:2.6s
  • seq0.2s
  • pseq0.5s

我正在使用GHC-8.2.2并使用stack ghc -- File.hs命令进行编译。

尝试使用stack ghc -- -O File.hs命令进行编译后,seqpseq之间的性能差距消失了。他们现在都在0.2s

我的实现是否展示了我想要的属性?或者GHC有一些实施的怪癖?为什么pseq会变慢?是否存在seq a b具有不同结果的示例,具体取决于评估顺序(相同的代码但不同的编译器标志/不同的编译器/等)?

3 个答案:

答案 0 :(得分:11)

到目前为止,答案主要集中在seqpseq表现问题上,但我认为您原本想知道应该使用哪两个。

简短的回答是:虽然两者都应该在实践中生成几乎相同的代码(至少在打开正确的优化标志时),原始seq而不是pseq是正确的选择为了你的情况。从绩效的角度来看,使用pseq是非惯用的,令人困惑的,并且可能适得其反,而您使用它的原因是基于对其评估顺序保证的含义以及它所暗示的含义的错误理解。性能。虽然不能保证不同编译器标志集之间的性能(更不用说其他编译器),但是如果遇到上述代码的seq版本运行速度明显慢于pseq的情况。版本使用"生产质量"使用GHC编译器的优化标志,您应该将其视为GHC错误并提交错误报告。

当然,答案很长......

首先,让我们清楚seqpseq 语义相同,因为它们都满足等式:

seq _|_ b = _|_
seq a b = b -- if a is not _|_
pseq _|_ b = _|_
pseq a b = b -- if a is not _|_

这实际上是他们中任何一个语义保证的唯一东西,并且由于Haskell语言的定义(如Haskell报告中所述)只能使 - 充其量 - 语义保证并且不处理性能或在实现中,由于不同编译器或编译器标志的性能保证,没有理由在一个或另一个之间进行选择。

此外,在基于seq的函数sum的特定版本中,不难发现没有seq调用seq的情况一个未定义的第一个参数,但是一个定义的第二个参数(假设使用标准的数字类型),所以你甚至使用 seq的语义属性。您可以将seq a b = b重新定义为seq并具有完全相同的语义。当然,你知道这一点 - 这就是你的第一个版本没有使用seq的原因。相反,您使用seq来表示偶然的性能副作用,因此我们不再局限于语义保证领域,而是回到特定GHC编译器实现和性能特征的领域(那里有)实际上并不是保证。)

其次,这将我们带到seq a b预期目的。它很少用于其语义属性,因为这些属性并不是非常有用。谁希望计算b返回a ,除了,如果某些不相关的表达式seq无法终止,它将无法终止? (例外 - 没有双关语意味着 - 就像处理异常一样,您可以使用基于deepSeq的{​​{1}}或seq来强制评估非终止表达式在开始评估另一个表达式之前,以不受控制或受控的方式。)

相反,seq a b旨在强制评估a到弱头正常形式,然后返回b的结果以防止积累thunk。这个想法是,如果你有一个表达式b来构建一个可能在a代表的另一个未评估的thunk之上累积的thunk,你可以使用seq a b来阻止这种累积。 "保证"是一个弱者:GHC保证,当需要a的价值时,GHC明白你不希望seq a b保持未评估的价值。从技术上讲,它并不能保证在{&#34}之前评估ab,无论那意味着什么,但你不需要这种保证。当你担心,如果没有这个保证,GHC可能会首先评估递归调用并仍然累积thunk,这和pseq a b可能评估它的第一个参数,然后等待15分钟(只是为了绝对确定第一个参数已被评估!),然后评估它的第二个参数一样荒谬。

在这种情况下,您应该相信GHC做正确的事情。您可能认为,实现seq a b的性能优势的唯一方法是在a开始评估之前将b评估为WHNF,但可以想象有优化在这种或其他情况下,技术上开始评估b(或甚至完全评估b到WHNF),同时让a在短时间内未评估以提高性能,同时仍保留{{{1}的语义1}}。通过使用seq a b代替,您可以阻止GHC进行此类优化。 (在您的pseq计划情况下,毫无疑问没有这样的优化,但在更sum的更复杂使用中,可能存在。)

第三,了解seq实际 的重要性。它首先在Marlow 2009中在并发编程的上下文中描述。假设我们要并行化两个昂贵的计算pseqfoo,然后合并(比如说,添加)他们的结果:

bar

这里的意图是 - 当需要这个表达式的值时 - 它会创建一个并行计算foo `par` (bar `seq` foo+bar) -- parens redundant but included for clarity 的火花,然后通过foo表达式开始评估{在最终评估seq之前{1}}到WHNF(也就是它的数值),它会在添加并返回结果之前等待bar的火花。

在这里,可以想象GHC会认识到对于特定的数字类型,(1)foo+bar如果foo那么自动终止,则满足{{{}的正式语义保证。 1}}; (2)评估foo+bar到WHNF将自动强制评估bar到WHNF,防止任何thunk积累,从而满足seq的非正式实施保证。在这种情况下,GHC可以随意优化foo+bar以产生:

bar

特别是如果在完成对WHNF的评估seq之前认为开始评估seq会更有效率。

GHC没有足够聪明地意识到 - 如果foo `par` foo+bar foo+bar的评估在预定bar火花之前开始,那么火花就会消失,不会发生并行执行。

实际上只有在这种情况下,你需要明确地延迟要求激发表达式的值,以便在主线程之前安排它的安排"赶上"您需要foo的额外保证,并且愿意让GHC放弃foo+bar的较弱保证所允许的额外优化机会:

foo

此处,pseq会阻止GHC引入任何可能允许seqfoo `par` (bar `pseq` foo+bar) 处于WHNF之前开始评估(可能会使pseq火花失效)的优化(其中我们希望,有足够的时间来安排火花。

结果是,如果您使用foo+bar进行并发编程以外的任何操作,则表示您使用错误。 (好吧,也许有一些奇怪的情况,但是......)如果你想做的就是强制严格评估和/或thunk评估以提高非并发代码的性能,使用foo(或{{1根据{{​​1}}或Haskell严格数据类型(以bar定义)定义的是正确的方法。

(或者,如果要相信@ Kindaro的基准测试,那么使用特定的编译器版本和标志进行无情的基准测试是正确的方法。)

答案 1 :(得分:6)

我只看到关闭优化的这种差异。 使用ghc -O pseqseq执行相同的操作。

seq的宽松语义允许转换,导致代码确实更慢。我想不出实际发生的情况。我们只是假设GHC做对了。不幸的是,我们没有办法用Haskell的高级语义来表达这种行为。

  

为什么pseq更慢?

pseq x y = x `seq` lazy y
因此,使用pseq实现

seq。观察到的开销是由于调用pseq的额外间接。

即使这些最终得到优化,使用pseq代替seq也未必是个好主意。虽然更严格的排序语义似乎意味着预期的效果(go不会累积thunk),但它可能会禁用一些进一步的优化:也许评估x和评估y可以分解为低级操作,其中一些我们不介意跨越pseq边界。

  

是否存在一些示例,其中seq a b具有不同的结果,具体取决于评估顺序(相同的代码但不同的编译器标志/不同的编译器/等)?

这可以抛出"a""b"

seq (error "a") (error "b")

我想在Haskell中有关于例外的论文中解释了A Semantics for imprecise exceptions

答案 2 :(得分:3)

编辑:我的理论被挫败了,因为我观察到的时间实际上受到剖析本身的影响而严重扭曲;在剖析中,数据违背了理论。此外,GHC版本之间的时间差异很大。我现在正在收集更好的观察结果,当我到达一个决定性的点时,我将进一步编辑这个答案。

关于问题"为什么pseq更慢",我有一个理论。

    • 让我们将acc' `seq` go acc' xs重新定义为strict (go (strict acc') xs)
    • 同样,acc' `pseq` go acc' xs被重新措辞为lazy (go (strict acc') xs)
    • 现在,在go acc (x:xs) = let ... in ...的情况下,让我们将go acc (x:xs) = strict (go (x + acc) xs)重新定义为seq
    • go acc (x:xs) = lazy (go (x + acc) xs)的情况为pseq
  1. 现在,很容易看出,在pseq的情况下,go被分配了一个懒惰的thunk,将在稍后的某个时间点进行评估。在sum的定义中,go从未出现在pseq的左侧,因此,在sum的运行过程中,根本不会强制进行评估。此外,每次go的递归调用都会发生这种情况,因此thunk会累积。

    这是一个用稀薄的空气建造的理论,但我确实有一个部分证据。具体来说,我确实发现gopseq情况下分配了线性记忆,但在seq的情况下却没有。如果运行以下shell命令,您可能会看到自己:

    for file in SumNaive.hs SumPseq.hs SumSeq.hs 
    do
        stack ghc                \
            --library-profiling  \
            --package parallel   \
            --                   \
            $file                \
            -main-is ${file%.hs} \
            -o ${file%.hs}       \
            -prof                \
            -fprof-auto
    done
    
    for file in SumNaive.hs SumSeq.hs SumPseq.hs
    do
        time ./${file%.hs} +RTS -P
    done
    

    - 并比较go成本中心的内存分配。

    COST CENTRE             ...  ticks     bytes
    SumNaive.prof:sum.go    ...    782 559999984
    SumPseq.prof:sum.go     ...    669 800000016
    SumSeq.prof:sum.go      ...    161         0
    

    postscriptum

    由于对哪些优化实际起到什么影响的问题似乎存在不和谐,我将使用我的确切源代码和time度量,以便有一个共同的基线。

    SumNaive.hs

    module SumNaive where
    
    import Prelude hiding (sum)
    
    sum :: Num a => [a] -> a
    sum = go 0
      where
        go acc []     = acc
        go acc (x:xs) = go (x + acc) xs
    
    main = print $ sum [1..10^7]
    

    SumSeq.hs

    module SumSeq where
    
    import Prelude hiding (sum)
    
    sum :: Num a => [a] -> a
    sum = go 0
      where
        go acc []     = acc
        go acc (x:xs) = let acc' = x + acc
                        in acc' `seq` go acc' xs
    
    main = print $ sum [1..10^7]
    

    SumPseq.hs

    module SumPseq where
    
    import Prelude hiding (sum)
    import Control.Parallel (pseq)
    
    sum :: Num a => [a] -> a
    sum = go 0
      where
        go acc []     = acc
        go acc (x:xs) = let acc' = x + acc
                        in acc' `pseq` go acc' xs
    
    main = print $ sum [1..10^7]
    

    没有优化的时间:

    ./SumNaive +RTS -P  4.72s user 0.53s system 99% cpu 5.254 total
    ./SumSeq +RTS -P  0.84s user 0.00s system 99% cpu 0.843 total
    ./SumPseq +RTS -P  2.19s user 0.22s system 99% cpu 2.408 total
    

    -O的时间:

    ./SumNaive +RTS -P  0.58s user 0.00s system 99% cpu 0.584 total
    ./SumSeq +RTS -P  0.60s user 0.00s system 99% cpu 0.605 total
    ./SumPseq +RTS -P  1.91s user 0.24s system 99% cpu 2.147 total
    

    -O2的时间:

    ./SumNaive +RTS -P  0.57s user 0.00s system 99% cpu 0.570 total
    ./SumSeq +RTS -P  0.61s user 0.01s system 99% cpu 0.621 total
    ./SumPseq +RTS -P  1.92s user 0.22s system 99% cpu 2.137 total
    

    可以看出:

    • Naive变体在没有优化的情况下表现不佳,但-O-O2表现出色,表现优于所有其他变体。

    • seq变体具有良好的性能,通过优化几乎没有改进,因此使用-O-O2 Naive变体优于它。

    • pseq变体的效果一直很差,比没有优化的Naive变体好两倍,比其他-O-O2差四倍。优化会影响它与seq变种一样少。