Зачем избегать явной рекурсии в Haskell? - PullRequest
6 голосов
/ 22 октября 2019

Я новичок в Haskell.

При изучении foldr многие предлагают использовать его и избегать явной рекурсии, которая может привести к неэффективному коду кода. https://www.reddit.com/r/haskell/comments/1nb80j/proper_use_of_recursion_in_haskell/

Поскольку я запускал образец, упомянутый в ссылке выше. Я вижу, что явная рекурсия работает лучше с точки зрения памяти. Сначала я подумал, что может работать на GHCi не близко к идеальному тесту, и я попытался скомпилировать его, используя stack ghc. И кстати, как я могу передать флаги оптимизации компилятора через стек GHC. Чего мне не хватает в выражении Избегайте явной рекурсии .

find p = foldr go Nothing
    where go x rest = if p x then Just x else rest

findRec :: (a -> Bool) -> [a] -> Maybe a
findRec _ [] = Nothing
findRec p (x:xs) = if p x then Just x else (findRec p xs)

main :: IO ()
main = print $ find (\x -> x `mod` 2 == 0) [1, 3..1000000] 
main = print $ findRec (\x -> x `mod` 2 == 0) [1, 3..1000000] 

-- find
Nothing
      92,081,224 bytes allocated in the heap
           9,392 bytes copied during GC
          58,848 bytes maximum residency (2 sample(s))
          26,704 bytes maximum slop
               0 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0        87 colls,     0 par    0.000s   0.000s     0.0000s    0.0001s
  Gen  1         2 colls,     0 par    0.000s   0.001s     0.0004s    0.0008s

  INIT    time    0.000s  (  0.000s elapsed)
  MUT     time    0.031s  (  0.043s elapsed)
  GC      time    0.000s  (  0.001s elapsed)
  EXIT    time    0.000s  (  0.000s elapsed)
  Total   time    0.031s  (  0.044s elapsed)

  %GC     time       0.0%  (0.0% elapsed)

  Alloc rate    2,946,599,168 bytes per MUT second

  Productivity 100.0% of total user, 96.8% of total elapsed

-- findRec
Nothing
      76,048,432 bytes allocated in the heap
          13,768 bytes copied during GC
          42,928 bytes maximum residency (2 sample(s))
          26,704 bytes maximum slop
               0 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0        71 colls,     0 par    0.000s   0.000s     0.0000s    0.0001s
  Gen  1         2 colls,     0 par    0.000s   0.001s     0.0004s    0.0007s

  INIT    time    0.000s  (  0.000s elapsed)
  MUT     time    0.031s  (  0.038s elapsed)
  GC      time    0.000s  (  0.001s elapsed)
  EXIT    time    0.000s  (  0.000s elapsed)
  Total   time    0.031s  (  0.039s elapsed)

  %GC     time       0.0%  (0.0% elapsed)

  Alloc rate    2,433,549,824 bytes per MUT second

  Productivity 100.0% of total user, 96.6% of total elapsed

Ответы [ 2 ]

11 голосов
/ 22 октября 2019

Вы измеряете, как быстро GHC может выполнять полмиллиона операций модуля. Как и следовало ожидать, «в мгновение ока» - это ответ независимо от того, как вы выполняете итерацию. Нет очевидных различий в скорости.

Вы утверждаете, что видите, что явная рекурсия использует меньше памяти, но предоставленные вами данные профилирования кучи показывают обратное: большее выделение и более высокая максимальная резидентность при использовании явной рекурсии. Я не думаю, что разница значительна, но если бы это было так, то ваши доказательства противоречили бы вашему утверждению.

Что касается вопроса о том, почему следует избегать явной рекурсии, не совсем понятно, какую часть этого потока выпрочитайте, что заставило вас прийти к вашему выводу. Вы связаны с гигантской нитью, которая сама ссылается на другую гигантскую нить со многими конкурирующими мнениями. Наиболее выдающийся комментарий для меня - , речь идет не об эффективности, а об уровнях абстракции . Вы смотрите на это неправильно, пытаясь измерить его эффективность.

4 голосов
/ 23 октября 2019

Во-первых, не пытайтесь понять производительность кода, скомпилированного GHC, используя что-либо кроме оптимизированной компиляции:

$ stack ghc -- -O2 Find.hs
$ ./Find +RTS -s

С флагом -O2 (и GHC версии 8.6.4) вашfind работает следующим образом:

      16,051,544 bytes allocated in the heap
          14,184 bytes copied during GC
          44,576 bytes maximum residency (2 sample(s))
          29,152 bytes maximum slop
               0 MB total memory in use (0 MB lost due to fragmentation)

Однако это очень вводит в заблуждение. Ни одно из этого использования памяти не связано с циклом, выполняемым foldr. Скорее это все из-за использования в штучной упаковке Integers. Если вы переключитесь на обычный Ints, который компилятор может распаковать:

main = print $ find (\x -> x `mod` 2 == 0) [1::Int, 3..1000000]
                                             ^^^^^

, производительность памяти резко изменится и продемонстрирует истинную стоимость памяти foldr:

      51,544 bytes allocated in the heap
       3,480 bytes copied during GC
      44,576 bytes maximum residency (1 sample(s))
      25,056 bytes maximum slop
           0 MB total memory in use (0 MB lost due to fragmentation)

Есливы тестируете findRec с Ints следующим образом:

 main = print $ findRec (\x -> x `mod` 2 == 0) [1::Int, 3..1000000]

вы увидите гораздо худшую производительность памяти:

  40,051,528 bytes allocated in the heap
      14,992 bytes copied during GC
      44,576 bytes maximum residency (2 sample(s))
      29,152 bytes maximum slop
           0 MB total memory in use (0 MB lost due to fragmentation)

, что, кажется, убедительно доказывает, что рекурсия должна бытьпредпочтительнее избегать foldr, но это тоже вводит в заблуждение. То, что вы видите здесь, это не стоимость памяти рекурсии, а скорее стоимость памяти для "построения списка".

Смотрите, foldr и выражение [1::Int, 3..1000000] оба включают некоторыемагия называется "список слияния". Это означает, что когда они используются вместе (то есть, когда foldr применяется к [1::Int 3..1000000]), можно выполнить оптимизацию, чтобы полностью исключить создание списка Haskell. Важно отметить, что код foldr, даже используя объединение списков, компилируется в рекурсивный код, который выглядит следующим образом:

main_go
  = \ x ->
      case gtInteger# x lim of {
        __DEFAULT ->
          case eqInteger# (modInteger x lvl) lvl1 of {
            __DEFAULT -> main_go (plusInteger x lvl);
                      -- ^^^^^^^ - SEE?  IT'S JUST RECURSION
            1# -> Just x
          };
        1# -> Nothing
      }
end Rec }

Таким образом, это объединение списков, а не «избегание рекурсии», которое делает find быстрее, чемfindRec.

Вы можете убедиться в этом, если учесть производительность:

find1 :: Int -> Maybe Int
find1 n | n >= 1000000 = Nothing
        | n `mod` 2 == 0 = Just n
        | otherwise = find1 (n+2)

main :: IO ()
main = print $ find1 1

Даже при использовании рекурсии список не создается (или не используется в штучной упаковке * 1047). *), поэтому он работает так же, как foldr версия:

      51,544 bytes allocated in the heap
       3,480 bytes copied during GC
      44,576 bytes maximum residency (1 sample(s))
      25,056 bytes maximum slop
           0 MB total memory in use (0 MB lost due to fragmentation)

Итак, какие уроки можно взять домой?

  • Всегда тестируйте код на Haskell, используя ghc -O2,никогда GHCi или ghc без флагов оптимизации.
  • Менее 10% людей в любой ветке Reddit знают, о чем говорят.
  • foldr иногда может работать лучше, чем явная рекурсиякогда могут применяться специальные оптимизации, такие как слияние списков.
  • Но в общем случае явная рекурсия выполняется так же, как foldr или другие специализированные конструкции.
  • Кроме того, оптимизация кода на Haskell является сложной задачей.

На самом деле, вот лучший (более серьезный) урок на дому. Особенно, когда вы начинаете работать с Haskell, приложите все возможные усилия, чтобы не думать об «оптимизации» вашего кода. Гораздо больше, чем любой другой язык, который я знаю, существует огромный разрыв между кодом, который вы пишете, и кодом, генерируемым компилятором, поэтому даже не пытайтесь выяснить это прямо сейчас. Вместо этого напишите код, который будет понятен, понятен и идиоматичен. Если вы попытаетесь выучить «правила» для высокопроизводительного кода сейчас, вы поймете их все неправильно и выучите действительно плохой стиль программирования за сделку.

...