Необыкновенный шаблон не утечки памяти в рекурсии, но почему?

Функция mapAndSum в кодовом блоке все ниже образом сочетает map и sum (неважно, что в основной функции применяется другая 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 несут ссылку на всю пару, что приводит к хранению всего списка в памяти.

Мы (мой коллега Тони Дитце и я) решили это с помощью явного выражения case (сравните "плохо" с "хорошим2" ). Кстати, выяснив это, нам пришлось немало времени...!

Теперь, что мы не понимаем, в два раза:

  • Почему mapAndSum работает в первую очередь? Он также использует неопровержимый шаблон, поэтому он также должен хранить весь список в памяти, но, очевидно, этого не делает. И преобразование let в case приведет к тому, что функция будет вести себя совершенно неинтересно (до того, что стек переполнится, а не каламбур).

    Мы рассмотрели "основной" код, созданный GHC, но, насколько мы могли его интерпретировать, он фактически содержал тот же перевод let, что и выше. Таким образом, нет никакой подсказки здесь и больше путаницы.

  • Почему "плохо"? работать, когда оптимизация отключена, но не когда она включена?

Одно замечание относительно нашего фактического применения: мы хотим добиться обработки потока (форматирования) большого текстового файла, одновременно накапливая некоторое значение, которое затем записывается в отдельный файл. Как было указано, мы добились успеха, но остались два вопроса, и ответы могут улучшить наше понимание 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 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

Позвольте мне сначала ответить, почему mapAndSome может работать хорошо: то, что вы видите (очень вероятно), влияет на оптимизацию, описанную Филиппом Вадлером в " Устранение некоторых утечек пространства с сборщиком мусора". Краткое описание: Если сборщик мусора видит, что тень формы fst x и x уже оценивается конструктором кортежа, например. (y,z), он заменит fst x на y, возможно высвобождая z, если он не упоминается нигде.

В вашем коде s' будет, когда результат go будет оцениваться кортежем и после одного раунда GCing не будет содержать ссылку на кортеж, но будет заменен накопленным параметром.

Теперь рассмотрим другие шаблоны, исследуя ядро. Связывание foo скомпилировано с помощью:

foo_r2eT :: ([Type.Integer], Type.Integer)

foo_r2eT =
  case $wgo_r2eP mapAndSum1 lvl2_r2eS
  of _ { (# ww1_s2d7, ww2_s2d8 #) ->
  (ww1_s2d7, ww2_s2d8)
  }

Вот код в случае "bad" (lvl18_r2fd "плохой" ):

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_r2fc и lvl16_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 все еще жив и сохраняет полный кортеж. И поскольку сборщик мусора (кажется) не видит, что это селекторный удар, оптимизация Wadlers не срабатывает.

Напротив, здесь приведен код для "good1" a.k.a. 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

В реализация из first через *** используются опровержимые шаблоны, поэтому сгенерирован тип селектора, который хорошо обрабатывает сборщик мусора.

В оптимизированном случае вещи немного разбросаны, но в любом случае здесь есть соответствующий код (последнее значение - это то, которое печатается):

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 выглядит как селектор (так что Id ожидает, что среда выполнения применит оптимизацию Wadlers). Может быть, это не работает для статических трюков? Действительно, commentary, если он обновлен, говорит только о динамических выделенных селекторах. Поэтому, если ваш foo не был значением уровня модуля (или, скорее, лениво-лифтингом в один), а скорее зависел от некоторого ввода, w1_rf24 мог бы быть динамически распределен и, следовательно, иметь право на специальное лечение. Но тогда код может выглядеть очень по-другому.