Эффективность unfoldr против zipWith

на проверку кода, я ответил на вопрос о наивное решение Haskell fizzbuzz предложив реализацию, что проходит вперед, избегая квадратичной стоимости увеличения числа простых чисел и отбрасывая деление по модулю (почти) полностью. Вот код:

fizz :: Int -> String
fizz = const "fizz"

buzz :: Int -> String
buzz = const "buzz"

fizzbuzz :: Int -> String
fizzbuzz = const "fizzbuzz"

fizzbuzzFuncs =  cycle [show, show, fizz, show, buzz, fizz, show, show, fizz, buzz, show, fizz, show, show, fizzbuzz]

toFizzBuzz :: Int -> Int -> [String]
toFizzBuzz start count =
    let offsetFuncs = drop (mod (start - 1) 15) fizzbuzzFuncs
    in take count $ zipWith ($) offsetFuncs [start..]

в качестве дополнительного приглашения я предложил переписать его с помощью Data.List.unfoldr. The unfoldr версия является очевидной, простой модификацией этого кода, поэтому я не собираюсь введите его здесь, если люди, желающие ответить на мой вопрос, настаивают, что это важно (нет спойлеров для OP над обзором кода). Но у меня есть вопрос об относительной эффективности unfoldr решение по сравнению с zipWith один. Хотя я больше не Неофит Хаскелла, я не эксперт по внутренностям Хаскелла.

An unfoldr решение не требует [start..] бесконечный список, так как он может просто развернутся от start. Мои мысли

  1. в zipWith решение не запоминает каждый последующий элемент [start..] как его просят. Каждый элемент используется и отбрасывается, потому что нет ссылки на начало [start..] содержащийся. Таким образом, там потребляется не больше памяти, чем с unfoldr.
  2. опасения по поводу производительности unfoldr и последние патчи, чтобы сделать его всегда встроенным, проводятся на уровне, которого я еще не достиг.

поэтому я думаю, что эти два эквивалентны в потреблении памяти, но понятия не имею об относительной производительности. Надеясь, что больше прошлого года Haskellers может направить меня к пониманию этого.

unfoldr Кажется естественным использовать для генерации последовательностей, даже если другие решения более выразительны. Я просто знаю, что мне нужно больше понять о реальной производительности. (Почему-то я нахожу foldr гораздо легче понять на этом уровне)

Примечание: unfoldrС использованием Maybe была первая потенциальная производительность проблема, которая пришла мне в голову, прежде чем я даже начал исследовать проблему (и единственный бит оптимизационных/встроенных обсуждений, которые я полностью понял). Таким образом, я смог перестать беспокоиться о Maybe сразу (учитывая последнюю версию Haskell).

1 ответов


как ответственный за последние изменения в реализациях zipWith и unfoldr, я подумал, что мне, вероятно,стоит попробовать. Я не могу сравнивать их так легко, потому что они очень разные функции, но я могу попытаться объяснить некоторые из их свойств и значение изменений.

unfoldr

Inlining

старая версия unfoldr (ранее base-4.8 / GHC 7.10) был рекурсивным на верхнем уровне (он назывался сама напрямую). GHC никогда не строит рекурсивные функции, поэтому unfoldr никогда не была подставлена. В результате GHC не мог видеть, как он взаимодействовал с переданной функцией. Самым тревожным следствием этого было то, что функция передавалась, типа (b -> Maybe (a, b)), фактически произвести Maybe (a, b) значения, выделяя память для удержания Just и (,) конструкторы. Путем реструктуризации unfoldr как "работник" и "оболочка", новый код позволяет GHC встроить его и (во многих случаях) сплавить его с функция передана, поэтому дополнительные конструкторы удаляются оптимизациями компилятора.

например, под GHC 7.10, код

module Blob where
import Data.List

bloob :: Int -> [Int]
bloob k = unfoldr go 0 where
  go n | n == k    = Nothing
       | otherwise = Just (n * 2, n+1)

составлен с ghc -O2 -ddump-simpl -dsuppress-all -dno-suppress-type-signatures ведет к ядру

$wbloob :: Int# -> [Int]
$wbloob =
  \ (ww_sYv :: Int#) ->
    letrec {
      $wgo_sYr :: Int# -> [Int]
      $wgo_sYr =
        \ (ww1_sYp :: Int#) ->
          case tagToEnum# (==# ww1_sYp ww_sYv) of _ {
            False -> : (I# (*# ww1_sYp 2)) ($wgo_sYr (+# ww1_sYp 1));
            True -> []
          }; } in
    $wgo_sYr 0

bloob :: Int -> [Int]
bloob =
  \ (w_sYs :: Int) ->
    case w_sYs of _ { I# ww1_sYv -> $wbloob ww1_sYv }

Fusion

другое изменение unfoldr переписывал его для участия в fold / build fusion, структуре оптимизации, используемой в библиотеках списков GHC. Идея как" сложить/построить "слияние, так и более новый, по-разному сбалансированный" поток fusion " (используется в vector библиотека) заключается в том, что если список производится "хорошим производителем", преобразуется "хорошими трансформаторами" и потребляется "хорошим потребителем", то список никогда не нужно выделять вообще. Старый unfoldr был не хороший производитель, поэтому, если вы создали список с unfoldr и потребил его с, скажем, foldr, части списка будут выделены (и сразу же станут мусором) по мере продолжения вычислений. Теперь,unfoldr - это хорошо продюсер, так что вы можете написать цикл, используя, скажем,unfoldr, filter и foldr, и не (обязательно) выделять какую-либо память вообще.

например, учитывая приведенное выше определение bloob, и суровый {-# INLINE bloob #-} (этот материал немного хрупкий; хорошие производители иногда должны быть явно встроены, чтобы быть хорошими), код

hooby :: Int -> Int
hooby = sum . bloob

компилируется в GHC core

$whooby :: Int# -> Int#
$whooby =
  \ (ww_s1oP :: Int#) ->
    letrec {
      $wgo_s1oL :: Int# -> Int# -> Int#
      $wgo_s1oL =
        \ (ww1_s1oC :: Int#) (ww2_s1oG :: Int#) ->
          case tagToEnum# (==# ww1_s1oC ww_s1oP) of _ {
            False -> $wgo_s1oL (+# ww1_s1oC 1) (+# ww2_s1oG (*# ww1_s1oC 2));
            True -> ww2_s1oG
          }; } in
    $wgo_s1oL 0 0

hooby :: Int -> Int
hooby =
  \ (w_s1oM :: Int) ->
    case w_s1oM of _ { I# ww1_s1oP ->
    case $whooby ww1_s1oP of ww2_s1oT { __DEFAULT -> I# ww2_s1oT }
    }

который не имеет списков, нет Maybes и без пар; единственное распределение, которое он выполняет это Int используется для хранения конечного результата (приложение I# to ww2_s1oT). Весь расчет может быть выполнено в регистрах машины.

zipWith

zipWith имеет немного странную историю. Он вписывается в структуру fold/build немного неуклюже (я считаю, что он работает немного лучше с потоковым слиянием). Можно сделать zipWith fuse с его первым или вторым аргументом списка, и в течение многих лет список библиотека попыталась сделать так, чтобы он слился с любым, если любой из них был хорошим продюсером. К сожалению, сделать его плавким со своим вторым аргументом списка может сделать программу менее определенными при определенных обстоятельствах. То есть программа, использующая zipWith может работать нормально при компиляции без оптимизации, но выдаст ошибку при компиляции с оптимизацией. Это не очень хорошая ситуация. Следовательно, по состоянию на base-4.8, zipWith больше не пытается слиться со своим вторым аргументом списка. Если хочешь. чтобы слиться с хорошим продюсером, этот хороший продюсер должен быть в первом списке аргументов.

в частности, примером реализации zipWith приводит к ожиданию, что, скажем,zipWith (+) [1,2,3] (1 : 2 : 3 : undefined) даст [2,4,6], потому что он останавливается, как только он попадает в конец первого списка. С предыдущим zipWith реализация, если второй список выглядел так, но был произведен хорошим производителем, и если zipWith случилось слиться с ним, а не с первым списком, тогда он пойдет бум.