【问题标题】:Isn't this a double traversal?这不是双向遍历吗?
【发布时间】:2023-12-07 02:31:01
【问题描述】:

haskell wiki的“编程技巧”部分,我找到了这个例子:

count :: (a -> Bool) -> [a] -> Int
count p = length . filter p

据说这是一个更好的选择

count :: (a -> Bool) -> [a] -> Int
count _ [] = 0
count p (x:xs)
   | p x       = 1 + count p xs
   | otherwise =     count p xs

就可读性而言,我完全同意。

但是,这不是双重遍历,因此实际上比显式递归函数更糟糕吗? GHC中的惰性是否意味着这相当于优化后的单次遍历?哪种实现更快,为什么?

【问题讨论】:

  • 当您在 GHC 中打开优化时,这两个遍历可能会融合为一个。但是,是的,原则上你是对的,虽然第二次遍历只是在较短的过滤列表上,所以无论如何它通常并不重要。
  • 第二个版本不是尾递归的,可能会导致使用比第一个更多的内存。您可以使用(严格)累加器将其保存在常量内存中。或者你可以使用foldl' (\c a -> if p a then succ c else c) 0
  • 你可能会喜欢guide to lazy evaluation :D

标签: haskell functional-programming traversal


【解决方案1】:

所以要看看优化器实际上做了什么,让我们look at the core

% ghc -O2 -ddump-simpl Temp.hs
[1 of 1] Compiling Temp             ( Temp.hs, Temp.o )

==================== Tidy Core ====================
Result size of Tidy Core = {terms: 29, types: 26, coercions: 0}

Temp.count
  :: forall a_arN.
     (a_arN -> GHC.Types.Bool) -> [a_arN] -> GHC.Types.Int
[GblId,
 Arity=2,
 Caf=NoCafRefs,
 Str=DmdType <L,C(U)><S,1*U>,
 Unf=Unf{Src=<vanilla>, TopLvl=True, Arity=2, Value=True,
         ConLike=True, WorkFree=True, Expandable=True,
         Guidance=IF_ARGS [60 0] 191 0}]
Temp.count =
  \ (@ a_aMA)
    (p_arV :: a_aMA -> GHC.Types.Bool)
    (eta_B1 :: [a_aMA]) ->
    letrec {
      go_aNr [Occ=LoopBreaker]
        :: [a_aMA] -> GHC.Prim.Int# -> GHC.Types.Int
      [LclId, Arity=1, Str=DmdType <S,1*U>]
      go_aNr =
        \ (ds_aNs :: [a_aMA]) ->
          case ds_aNs of _ [Occ=Dead] {
            [] -> GHC.Types.I#;
            : y_aNx ys_aNy ->
              case p_arV y_aNx of _ [Occ=Dead] {
                GHC.Types.False -> go_aNr ys_aNy;
                GHC.Types.True ->
                  let {
                    g_a10B [Dmd=<L,C(U)>] :: GHC.Prim.Int# -> GHC.Types.Int
                    [LclId, Str=DmdType]
                    g_a10B = go_aNr ys_aNy } in
                  \ (x_a10C :: GHC.Prim.Int#) -> g_a10B (GHC.Prim.+# x_a10C 1)
              }
          }; } in
    go_aNr eta_B1 0

稍微清理一下:

Temp.count :: forall aType.  (aType -> Bool) -> [aType] -> Int
Temp.count = \(@ aType) (p :: aType -> Bool) (as :: [aType]) ->
  letrec {
    go :: [aType] -> GHC.Prim.Int# -> Int
    go = \(xs :: [aType]) ->
      case xs of _ {
        [] -> I#; -- constructor to make a GHC.Prim.Int# into an Int
        : y ys ->
          case p y of _ {
            False -> go ys;
            True ->
              let {
                go' :: GHC.Prim.Int# -> Int
                go' = go ys 
              } in \(x :: GHC.Prim.Int#) -> go' (GHC.Prim.+# x 1)
          }
      }; 
  } in go as 0

由于我们操作的是未装箱类型GHC.Prim.Int#all the additions are strict,所以我们只有一个循环遍历数据。

【讨论】:

    【解决方案2】:

    无论哪种方式,您都必须对每个项目执行一到两个操作。必要的一项是检查谓词。第二个加 1 取决于谓词的结果。

    所以,如果不考虑缓存等的影响,两种情况都会产生相同数量的操作。

    我们得到的图是在第一种情况下有两个单独的遍历,一个收集元素,一个计算元素。给定一个大于缓存可以处理的列表,这将减慢处理速度。这实际上发生在 strict 语言中。

    然而,Haskell 的懒惰在这里体现出来了。 filter 生成的列表被逐个元素地评估(存在),因为计数函数 length 需要它。然后由于length 仅将它们用于计数并且不保留对新创建列表的引用,因此这些元素可以立即免费被垃圾收集。所以在任何时候,计算过程中只占用O(1)内存。

    在第一个版本中构建结果“过滤”列表有一点开销。但与列表很大时出现的缓存效应相比,这通常可以忽略不计。 (对于小型列表,这可能很重要;这需要进行测试。)此外,它可能会根据编译器和选择的优化级别进行优化。

    更新。第二个版本实际上会消耗内存,正如其中一个 cmets 所指出的那样。为了公平比较,您需要在正确的位置使用累积参数和严格性注释器编写它,因为(我希望)length 已经这样编写了。

    【讨论】:

    • 我的理解正确吗? filterlength 的要求逐一构造过滤列表,而length 又将其解构。这种中间构造/解构可能被称为“第二次遍历”,但编译器可以将其优化掉。
    • @stholzm 对。我会这样说:由于懒惰,第一次和第二次遍历是交错的。优化编译器可以删除第二个,因为它所做的只是构造和破坏(以及将一个添加到其他数字)。
    【解决方案3】:

    您可以使用profiling 测试哪个版本更快,例如:

    module Main where
    
    
    main :: IO ()
    main = print countme'
    
    count' :: (a -> Bool) -> [a] -> Int
    count' p = length . filter p
    
    count :: (a -> Bool) -> [a] -> Int
    count _ [] = 0
    count p (x:xs)
       | p x       = 1 + count p xs
       | otherwise =     count p xs
    
    
    list = [0..93823249]
    
    countme' = count' (\x -> x `mod` 15 == 0) list
    countme = count (\x -> x `mod` 15 == 0) list
    

    然后运行ghc -O2 -prof -fprof-auto -rtsopts Main.hs./Main +RTS -p。它将产生文件 Main.prof。然后将主函数更改为使用countme 并比较结果。我的是:

    • 4.12s 用于隐式版本
    • 6.34s 用于显式版本

    如果您关闭优化,那么隐式优化仍然会稍微(但不多)快。

    除了前面已经解释过的融合和惰性之外,我想还有一个原因可能是lengthfilter是Prelude函数,可以更好地被编译器优化。

    【讨论】:

    • 感谢您的时间!只是想知道为什么数字 93823249?
    • 这是一个随机数。
    • 为什么要进行分析?剖析有助于找出在单个程序中在哪里花费了时间。这不是一个很好的基准测试工具。最好使用 criterion 之类的库进行基准测试。
    • 你有参考资料说明为什么分析不适合做基本的基准测试吗?我不是 Haskell 大师。