Рассуждение о производительности в Haskell

Следующие две программы Haskell для вычисления n-го члена последовательности Фибоначчи имеют значительно отличающиеся характеристики:

fib1 n =
  case n of
    0 -> 1
    1 -> 1
    x -> (fib1 (x-1)) + (fib1 (x-2))

fib2 n = fibArr !! n where
  fibArr = 1:1:[a + b | (a, b) <- zip fibArr (tail fibArr)]

Они очень близки к математически идентичным, но fib2 использует нотацию списка для memoize своих промежуточных результатов, а fib1 имеет явную рекурсию. Несмотря на возможность кэширования промежуточных результатов в fib1, время выполнения становится проблемой даже для fib1 25, предполагая, что рекурсивные шаги всегда оцениваются. Предоставляет ли ссылочная прозрачность что-либо для производительности Haskell? Как я могу узнать заранее, если это будет или не будет?

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


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


Добавление: Любое событие, которое может возникнуть по этому вопросу, возможно, захочет посмотреть слайды из Johan Tibell говорить о высокой производительности Haskell.

Ответ 1

В вашем конкретном примере Фибоначчи не очень сложно понять, почему второй должен работать быстрее (хотя вы не указали, что такое f2).

В основном это алгоритмическая проблема:

  • fib1 реализует чисто рекурсивный алгоритм и (насколько я знаю) у Haskell нет механизма для "неявной memoization".
  • fib2 использует явное memoization (используя список fibArr для хранения ранее вычисленных значений.

В целом, гораздо труднее сделать предположения о производительности для ленивого языка, такого как Haskell, чем для нетерпеливого. Тем не менее, если вы понимаете лежащие в основе механизмы (особенно для лени) и собираете опыт, вы сможете сделать некоторые "прогнозы" о производительности.

Ссылочная прозрачность увеличивает (потенциально) производительность (по крайней мере) двумя способами:

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

Если вы хотите больше узнать о причинах выбора дизайна (лень, чистота) Haskell, я бы предложил прочитать this.

Ответ 2

Рассуждение о производительности, как правило, сложно в Haskell и ленивых языках вообще, хотя и не невозможно. Некоторые методы описаны в Chris Okasaki Чисто функциональные структуры данных (также доступны онлайн в предыдущей версии).

Еще один способ обеспечить производительность - исправить порядок оценки, используя аннотации или стиль продолжения передачи. Таким образом, вы можете контролировать, когда вещи оцениваются.

В вашем примере вы можете рассчитать числа "снизу вверх" и передать предыдущие два числа на каждую итерацию:

fib n = fib_iter(1,1,n)
    where
      fib_iter(a,b,0) = a
      fib_iter(a,b,1) = a
      fib_iter(a,b,n) = fib_iter(a+b,a,n-1)

Это приводит к линейному алгоритму времени.

Всякий раз, когда у вас есть алгоритм динамического программирования, в котором каждый результат зависит от N предыдущих результатов, вы можете использовать эту технику. В противном случае вам, возможно, придется использовать массив или что-то совершенно другое.

Ответ 3

В вашей реализации fib2 используется memoization, но каждый раз, когда вы вызываете fib2, он восстанавливает "цельный" результат. Включите профилирование времени и размера ghci:

Prelude> :set +s

Если бы он делал memoisation между "звонками", последующие вызовы были бы быстрее и не использовали бы память. Вызовите fib2 20000 дважды и убедитесь сами.

Для сравнения - более идиоматическая версия, где вы определяете точный математический идентификатор:

-- the infinite list of all fibs numbers.
fibs = 1 : 1 : zipWith (+) fibs (tail fibs)

memoFib n = fibs !! n

действительно используют memoisation, явные, как вы видите. Если вы запустите memoFib 20000 дважды, вы увидите, что время и пространство заняты в первый раз, а второй вызов мгновен и не берет память. Никакая магия и никакая неявная memoization, как комментарий, возможно, намекнул.

Теперь о вашем первоначальном вопросе: оптимизация и рассуждение о производительности в Haskell...

Я бы не назвал себя экспертом в Haskell, я использовал его только 3 года, 2 из которых на моем рабочем месте, но мне пришлось оптимизировать и понять, как немного рассуждать о его производительности.

Как упоминалось в другом посте, лень - ваш друг и может помочь вам повысить производительность, но вы должны контролировать то, что лениво оценивается и что строго оценивается.

Проверьте это сравнение foldl vs foldr

foldl фактически хранит "как" для вычисления значения, то есть оно лени. В некоторых случаях вы экономили время и пространство, ленивые, как "бесконечные" фибры. Бесконечные "фибры" не генерируют все из них, но знают, как это сделать. Когда вы знаете, что вам нужно значение, которое вы могли бы, просто получите его "строго", говоря... Что там, где аннотации строгости полезны, чтобы вернуть вам контроль.

Я помню много раз читал, что в lisp вам нужно "свести к минимуму" consing.

Понимание того, что оценивается строго и как заставить его, важно, но так понимает, сколько "перехвата" вы делаете с памятью. Помните, что Haskell неизменен, это означает, что обновление "переменной" на самом деле создает копию с модификацией. Предоставление с помощью (:) значительно более эффективно, чем добавление с помощью (++), потому что (:) не копирует память в противоположность (++). Всякий раз, когда обновляется большой атомный блок (даже для одиночного char), весь блок необходимо скопировать для представления "обновленной" версии. Способ структурирования данных и их обновления может иметь большое влияние на производительность. Профайлер ghc - ваш друг и поможет вам определить их. Уверенный сборщик мусора быстро, но он не делает ничего быстрее!

Приветствия

Ответ 4

Помимо проблемы с memoization, fib1 также использует рекурсию без обратной привязки. Рекурсия Tailcall может быть автоматически преобразована в простое goto и выполняется очень хорошо, но рекурсия в fib1 не может быть оптимизирована таким образом, потому что для вычисления результата требуется кадр стека из каждого экземпляра fib1. Если вы переписали fib1, чтобы передать общее количество в качестве аргумента, что позволяет использовать хвостовой вызов вместо необходимости сохранять фрейм стека для окончательного добавления, производительность будет значительно улучшаться. Но не так много, как memoized пример, конечно:)

Ответ 5

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

Для получения дополнительной информации прочитайте статьи Колина Рунсимана.