Рассуждения о производительности в 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 использует нотацию списка для запоминания промежуточных результатов, в то время как fib1 имеет явную рекурсию. Несмотря на возможность кэширования промежуточных результатов в fib1, время выполнения становится проблемой даже для fib1 25, предполагая, что рекурсивные шаги всегда оцениваются. Вносит ли ссылочная прозрачность какой-либо вклад в производительность Haskell? Как я могу знать заранее, будет это или нет?

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


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


дополнение: кто-нибудь происходящее по этому вопросу может захотеть посмотреть на горки С Юхан Tibell ' s говорить о высокой производительности Haskell.

5 ответов


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

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

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

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

Референциальной прозрачности увеличивает (потенциально) производительность в (по крайней мере) двух пути:

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

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


рассуждения о производительности, как правило, трудно в Haskell и ленивых языках в целом, хотя и не невозможно. Некоторые методы описаны в Чисто Функциональные Структуры Данных (а также онлайн в предыдущей версии).

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

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

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 предыдущих результатов, вы можете использовать этот метод. В противном случае вам придется использовать массив или что-то совершенно другое.


ваша реализация 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 дважды, вы увидите время и пространство, занятое в первый раз, тогда второй вызов будет мгновенным и не займет памяти. Никакой магии и никаких неявных мемоизация как комментарий, возможно, намекал на.

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

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

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

проверьте это сравнение foldl против выражения производится

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

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

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

Ура


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


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

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