不可否认的模式在递归中不会泄漏内存,但为什么?

14
下面代码块中的mapAndSum函数结合了mapsum(忽略主函数中应用的另一个sum,它只是为了使输出紧凑)。map是惰性计算的,而sum是使用累加参数计算的。其思想是map的结果可以在没有完整列表的情况下消耗,并且(仅)之后“免费”获得sum。主函数表明我们在调用mapAndSum时存在不可反驳模式的问题。让我解释一下这个问题。

根据Haskell标准,不可反驳模式示例let (xs, s) = mapAndSum ... in print xs >> print s被转换为

(\ v ->    print (case v of { (xs, s) -> xs })
        >> print (case v of { (xs, s) -> s }))
$ mapAndSum ...

因此,两个print调用都携带有对整个pair的引用,这导致整个列表被保存在内存中。
我们(我的同事Toni Dietze和我)使用了一个显式的case语句来解决这个问题(参见"bad" vs "good2")。顺便说一下,找出这个问题花费了我们相当多的时间..!
现在,我们不理解的是两方面的:
  • 为什么mapAndSum能够正常工作?它也使用了一个无法匹配的模式,所以它应该保留整个列表在内存中,但显然它并没有。将let转换为case将使函数完全失去惰性(甚至会导致堆栈溢出;无任何双关语的意思)。

    我们查看了GHC生成的“core”代码,但就我们的理解而言,它实际上包含与上面的let相同的翻译。因此没有头绪,反而更加困惑。

  • 为什么“bad?”在关闭优化时可以工作,但在打开优化时却不能工作?

  • 关于我们实际应用的一个备注:我们希望在累加某些值的同时将大型文本文件进行流处理(格式转换),然后将其写入另一个文件。正如所示,我们取得了成功,但两个问题仍然存在,答案可能会改善我们对即将到来的任务和问题的GHC的理解。
    谢谢!
    {-# LANGUAGE BangPatterns #-}
    
    -- Tested with The Glorious Glasgow Haskell Compilation System, version 7.4.2.
    
    module Main where
    
    
    import Control.Arrow (first)
    import Data.List (foldl')
    import System.Environment (getArgs)
    
    
    mapAndSum :: Num a => (a -> b) -> [a] -> ([b], a)
    mapAndSum f = go 0
      where go !s (x : xs) = let (xs', s') = go (s + x) xs in (f x : xs', s')
                           -- ^ I have no idea why it works here. (TD)
            go !s []       = ([], s)
    
    
    main :: IO ()
    main = do
      let foo  = mapAndSum (^ (2 :: Integer)) [1 .. 1000000 :: Integer]
      let sum' = foldl' (+) 0
      args <- getArgs
      case args of
        ["bad" ]  -> let (xs, s) = foo in print (sum xs) >> print s
        ["bad?"]  -> print $ first sum' $ foo
                  -- ^ Without ghc's optimizer, this version is as memory
                  -- efficient as the “good” versions
                  -- With optimization “bad?” is as bad as “bad”. Why? (TD)
        ["good1"] -> print $ first' sum' $ foo
                       where first' g (x, y) = (g x, y)
        ["good2"] -> case foo of
                        (xs, s) -> print (sum' xs) >> print s
        ["good3"] -> (\ (xs, s) -> print (sum' xs) >> print s) $ foo
        _ -> error "Sorry, I do not understand."
    

    1
    你的mapAnsSum函数可以通过使用Data.Traversable中的mapAccumL来进行改进。 - Laar
    我猜你在评论中指的是“ghc的优化器”,对吗? - Joachim Breitner
    是的,抱歉,我们指的是ghc的优化器。 - buechse
    也许我漏掉了什么,但是你似乎没有使用任何不可否认的模式 - dave4420
    1
    本文详细介绍了如何将let表达式转换为带有不可反驳模式的case表达式:http://www.haskell.org/onlinereport/haskell2010/haskellch3.html#x8-440003.12。简而言之,模式绑定是惰性匹配的;隐式的~使这些模式成为不可反驳的。 - buechse
    1个回答

    18

    首先,让我回答为什么mapAndSome可以很好地工作:你看到的(很可能是)是Philip Wadler在“修复垃圾收集器中的一些空间泄漏”中描述的一种优化效果。简而言之:如果垃圾收集器看到一个形式为fst x的thunk,并且x已经求值为元组构造函数,例如(y,z),它将用y替换fst x,如果z没有被其他任何地方引用,则可能释放掉z

    在你的代码中,s'一旦go的结果求值为元组并经过一轮GC后,将不再保留对元组的引用,而会被累积参数替换。

    现在,让我们通过调查核心来看看其他模式。 foo绑定编译为:

    foo_r2eT :: ([Type.Integer], Type.Integer)
    
    foo_r2eT =
      case $wgo_r2eP mapAndSum1 lvl2_r2eS
      of _ { (# ww1_s2d7, ww2_s2d8 #) ->
      (ww1_s2d7, ww2_s2d8)
      }
    

    这是“坏”情况下的代码(lvl18_r2fd为“bad”):

    case eqString ds_dyA lvl18_r2fd of _ {
      False -> $wa_s2da new_s_a14o;
      True ->
        case ds1_dyB of _ {
          [] ->
            case Handle.Text.hPutStr2
                   Handle.FD.stdout lvl17_r2fc True new_s_a14o
            of _ { (# new_s1_X15h, _ #) ->
            Handle.Text.hPutStr2
              Handle.FD.stdout lvl16_r2fb True new_s1_X15h
            };
          : ipv_sIs ipv1_sIt -> $wa_s2da new_s_a14o
        }
    

    我们可以看到在模块级别打印了两个值,lvl17_r2fclvl16_r2fb,下面是它们的代码:

    lvl17_r2fc :: String
    [GblId]
    lvl17_r2fc =
      case foo_r2eT of _ { (xs_Xqp, s_Xq9) ->
      $w$cshowsPrec
        0
        (Data.List.sum_sum' xs_Xqp Data.List.genericDrop2)
        ([] @ Char)
      }
    
    lvl16_r2fb :: String
    [GblId]
    lvl16_r2fb =
      case foo_r2eT of _ { (xs_apS, s_Xqp) ->
      $w$cshowsPrec 0 s_Xqp ([] @ Char)
      }
    
    为什么它们被绑定在模块级别,而不在表达式内部?这是“惰性提升”的一种效果,另一种优化,可以增加共享,但有时会对空间性能产生不利影响。请参见 GHC 票据719 以了解此效果的另一个发生情况。
    所以,发生的情况是评估lvl17_r2fc会导致评估foo,并且左侧条目会惰性打印。不幸的是, lvl16_r2fb 仍然活着并保留完整元组。因为垃圾收集器(似乎)不能看到这是选择器的节流,所以 Wadler 的优化不会生效。
    相比之下,这里是"good1"也就是 lvl8_r2f1 的代码:
                case eqString ds_dyA lvl8_r2f1 of _ {
                  False -> $wa2_s2dI w3_s2cF;
                  True ->
                    case ds1_dyB of _ {
                      [] ->
                        Handle.Text.hPutStr2
                          Handle.FD.stdout lvl7_r2f0 True w3_s2cF;
                      : ipv_sHg ipv1_sHh -> $wa2_s2dI w3_s2cF
                    }
                } } in
    

    打印出的值是这个字符串:

    lvl7_r2f0 :: String
    [GblId]
    lvl7_r2f0 =
      case foo_r2eT of _ { (x_af6, y_af7) ->
      show_tuple
        (:
           @ ShowS
           (let {
              w2_a2bY [Dmd=Just L] :: Type.Integer
    
              w2_a2bY = lgo_r2eU mapAndSum1 x_af6 } in
            \ (w3_a2bZ :: String) ->
              $w$cshowsPrec 0 w2_a2bY w3_a2bZ)
           (:
              @ ShowS
              (\ (w2_a2bZ :: String) ->
                 $w$cshowsPrec 0 y_af7 w2_a2bZ)
              ([] @ ShowS)))
        ([] @ Char)
      }
    

    正如您所看到的,元组仅被拆分一次,并且两个值都被使用。因此,没有任何内容是整个元组的引用,它可以被垃圾收集器收集。对于"good2""good3"也是如此。

    现在来看一下"bad?": 在未经优化的情况下,我们得到以下代码:

     case eqString ds_dyA (unpackCString# "bad?")
                     of _ {
                       False -> fail2_dyN realWorld#;
                       True ->
                         case ds1_dyB of _ {
                           [] ->
                             $
                               @ (Type.Integer, Type.Integer)
                               @ (IO ())
                               (System.IO.print
                                  @ (Type.Integer, Type.Integer) $dShow_rzk)
                               ($
                                  @ ([Type.Integer], Type.Integer)
                                  @ (Type.Integer, Type.Integer)
                                  (Control.Arrow.first
                                     @ (->)
                                     Control.Arrow.$fArrow(->)
                                     @ [Type.Integer]
                                     @ Type.Integer
                                     @ Type.Integer
                                     sum'_rzm)
                                  foo_rzl);
                           : ipv_szd ipv1_sze -> fail2_dyN realWorld#
                         }
                     } } in
    

    Control-Arrow.htmlfirst的实现通过***使用可反驳的模式,因此会生成垃圾回收处理得当的选择器thunks。

    在优化的情况下,有点分散了,但是这里是相关代码(最后一个值是正在打印的值):

    w_r2f2 :: Type.Integer
    
    w_r2f2 =
      case foo_r2eT of _ { (x_aI1, y_aI2) ->
      lgo_r2eU mapAndSum1 x_aI1
      }
    
    lvl9_r2f3 :: String -> String
    [GblId, Arity=1]
    lvl9_r2f3 =
      \ (w2_a2bZ :: String) ->
        $w$cshowsPrec 0 w_r2f2 w2_a2bZ
    
    w1_r2f4 :: Type.Integer
    
    w1_r2f4 = case foo_r2eT of _ { (x_aI6, y_aI7) -> y_aI7 }
    
    lvl10_r2f5 :: String -> String
    [GblId, Arity=1]
    lvl10_r2f5 =
      \ (w2_a2bZ :: String) ->
        $w$cshowsPrec 0 w1_r2f4 w2_a2bZ
    
    lvl11_r2f6 :: [ShowS]
    [GblId]
    lvl11_r2f6 =
      :
        @ ShowS lvl10_r2f5 ([] @ ShowS)
    
    lvl12_r2f7 :: [ShowS]
    [GblId]
    lvl12_r2f7 = : @ ShowS lvl9_r2f3 lvl11_r2f6
    
    lvl13_r2f8 :: ShowS
    [GblId]
    lvl13_r2f8 = show_tuple lvl12_r2f7
    
    lvl14_r2f9 :: String
    [GblId]
    lvl14_r2f9 = lvl13_r2f8 ([] @ Char)
    
    first已内联。我们看到两个对case foo_r2eT的调用,因此这容易导致空间泄漏,尽管w1_rf24看起来像是一个选择器(所以我希望运行时应用Wadler的优化)。也许它在静态thunk上表现不佳?的确评论,如果最新的话,只谈论了动态分配的选择器thunk。因此,如果你的foo不是模块级值(或者可以懒惰地提升为模块级值),而是依赖于某些输入,w1_rf24可能是动态分配的,因此有资格进行特殊处理。但是无论如何,代码可能看起来非常不同。

    2
    好的,Haskell报告没有关于空间行为的声明,所以无论如何你都会迷失方向。是的,其他Haskell实现在每种情况下可能表现不佳。 - Joachim Breitner
    1
    我认为这就是像 pipe 库的作用,但我并不是在这方面的专家。 - Joachim Breitner
    1
    情况变得更糟,因为编译器有时无法应用Wadler建议的优化,例如请参见此线程http://www.haskell.org/pipermail/glasgow-haskell-users/2010-October/019408.html。在某些情况下,其他优化首先应用,而编译器无法识别程序投影到构造函数的一个组件的情况。 - Jan Christiansen
    1
    除了它不是由Phil Wadler发明的之外,一切都很好。Phil只是记录了它,但在他写那篇论文之前,至少有两个人独立发明了它。 - augustss
    可能也是,但它肯定可以在垃圾收集器中快速选择thunks:http://hackage.haskell.org/trac/ghc/browser/rts/sm/Evac.c#L1031 - Joachim Breitner
    显示剩余9条评论

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接