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

Over on Code Review, я ответил на вопрос о наивном решении 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. Версия unfoldr является очевидной простой модификацией этого кода, поэтому я не буду вводить ее здесь, если люди, желающие ответить на мой вопрос, не настаивают на том, что это важно (никаких спойлеров для OP над обзором кода). Но у меня есть вопрос об относительной эффективности решения unfoldr по сравнению с zipWith. Хотя я больше не неофит Хаскелла, я не эксперт по внутренним вопросам Haskell.

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

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

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

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

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

Ответ 1

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

unfoldr

Встраивание

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

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

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

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

$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 }
    }

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

zipWith

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

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