Как улучшить производительность этой программы Haskell?
Я работаю над проблемами Project Euler как способ обучения Haskell, и я считаю, что мои программы намного медленнее чем сопоставимая версия C, даже при компиляции. Что я могу сделать для ускорения моих программ Haskell?
Например, мое решение для грубой силы Проблема 14:
import Data.Int
import Data.Ord
import Data.List
searchTo = 1000000
nextNumber :: Int64 -> Int64
nextNumber n
| even n = n `div` 2
| otherwise = 3 * n + 1
sequenceLength :: Int64 -> Int
sequenceLength 1 = 1
sequenceLength n = 1 + (sequenceLength next)
where next = nextNumber n
longestSequence = maximumBy (comparing sequenceLength) [1..searchTo]
main = putStrLn $ show $ longestSequence
Занимает около 220 секунд, тогда как "эквивалентная" версия с грубой силой C занимает всего 1,2 секунды.
#include <stdio.h>
int main(int argc, char **argv)
{
int longest = 0;
int terms = 0;
int i;
unsigned long j;
for (i = 1; i <= 1000000; i++)
{
j = i;
int this_terms = 1;
while (j != 1)
{
this_terms++;
if (this_terms > terms)
{
terms = this_terms;
longest = i;
}
if (j % 2 == 0)
j = j / 2;
else
j = 3 * j + 1;
}
}
printf("%d\n", longest);
return 0;
}
Что я делаю неправильно? Или я наивно полагаю, что Haskell мог даже приблизиться к скорости C?
(Я компилирую версию C с gcc -O2 и версию Haskell с помощью ghc -make -O).
Ответы
Ответ 1
Для целей тестирования я только что установил searchTo = 100000
. Затраченное время 7.34s. Несколько изменений приводят к некоторому большому улучшению:
-
Используйте Integer
вместо Int64
. Это улучшает время 1,75 с.
-
Используйте аккумулятор (вам не нужна последовательностьLength, чтобы быть ленивой правильно?) 1.54s.
seqLen2 :: Int -> Integer -> Int
seqLen2 a 1 = a
seqLen2 a n = seqLen2 (a+1) (nextNumber n)
sequenceLength :: Integer -> Int
sequenceLength = seqLen2 1
-
Перепишите nextNumber
с помощью quotRem
, тем самым избегая вычисления разделения дважды (один раз в even
и один раз в div
). 1.27s.
nextNumber :: Integer -> Integer
nextNumber n
| r == 0 = q
| otherwise = 6*q + 4
where (q,r) = quotRem n 2
-
Используйте преобразование Шварца вместо maximumBy
. Проблема maximumBy . comparing
заключается в том, что функция sequenceLength
вызывается более одного раза для каждого значения. 0.32s.
longestSequence = snd $ maximum [(sequenceLength a, a) | a <- [1..searchTo]]
Примечание:
- Я проверяю время, компилируя с помощью
ghc -O
и запускаю с +RTS -s
)
- Моя машина работает в Mac OS X 10.6. Версия GHC - 6.12.2. Скомпилированный файл находится в архитектуре i386.)
- Задача C работает с 0,078 с с соответствующим параметром. Он скомпилирован с помощью
gcc -O3 -m32
.
Ответ 2
Хотя это уже довольно старо, позвольте мне перезвонить, есть один важный момент, который ранее не рассматривался.
Во-первых, тайминги разных программ на моем ящике. Поскольку я нахожусь в 64-битной Linux-системе, они показывают несколько разные характеристики: использование Integer
вместо Int64
не улучшает производительность, как это было бы при использовании 32-разрядного GHC, где каждая операция Int64
стоимость C-вызова, в то время как вычисления с Integer
фитингом в подписанных 32-битных целых числах не нуждаются в внешнем вызове (так как здесь только несколько операций превышают этот диапазон, Integer
- лучший выбор на 32-битном GHC).
- C: 0,3 секунды
- Оригинальный Haskell: 14.24 секунды, используя
Integer
вместо Int64
: 33.96 секунд
- Улучшенная версия KennyTM: 5.55 секунд, используя
Int
: 1.85 секунд
- версия Chris Kuklewicz: 5.73 секунды, используя
Int
: 1.90 секунд
- Версия FUZxxl: 3,56 секунды, используя
quotRem
вместо divMod
: 1,79 секунды
Итак, что мы имеем?
- Рассчитать длину с аккумулятором, чтобы компилятор мог преобразовать его (в основном) в цикл
- Не пересчитывайте длины последовательностей для сравнения
- Не используйте
div
соответственно. divMod
, когда это не необходимо, quot
соответственно. quotRem
намного быстрее
Что еще не хватает?
if (j % 2 == 0)
j = j / 2;
else
j = 3 * j + 1;
Любой компилятор C, который я использовал, преобразует тест j % 2 == 0
в бит-маскировку и не использует инструкцию деления. GHC пока не делает этого. Поэтому тестирование even n
или вычисление n `quotRem` 2
является довольно дорогостоящей операцией. Замена nextNumber
в версии KennyTM Integer
с помощью
nextNumber :: Integer -> Integer
nextNumber n
| fromInteger n .&. 1 == (0 :: Int) = n `quot` 2
| otherwise = 3*n+1
сокращает время его работы до 3,25 секунды (Примечание: для Integer
, n `quot` 2
работает быстрее, чем n `shiftR` 1
, что занимает 12,69 секунды!).
Выполнение этого же в версии Int
сокращает время работы до 0,41 секунды. Для Int
s бит-сдвиг для деления на 2 бит быстрее, чем операция quot
, сокращая время его работы до 0,39 секунды.
Устранение конструкции списка (не отображаемого в версии C),
module Main (main) where
import Data.Bits
result :: Int
result = findMax 0 0 1
findMax :: Int -> Int -> Int -> Int
findMax start len can
| can > 1000000 = start
| canlen > len = findMax can canlen (can+1)
| otherwise = findMax start len (can+1)
where
canlen = findLen 1 can
findLen :: Int -> Int -> Int
findLen l 1 = l
findLen l n
| n .&. 1 == 0 = findLen (l+1) (n `shiftR` 1)
| otherwise = findLen (l+1) (3*n+1)
main :: IO ()
main = print result
дает дополнительное небольшое ускорение, в результате чего время работы составляет 0,37 секунды.
Итак, версия Haskell, которая в близком соответствии с версией C не занимает гораздо больше времени, это коэффициент ~ 1.3.
Хорошо, пусть справедливо, есть неэффективность версии C, которая отсутствует в версиях Haskell,
if (this_terms > terms)
{
terms = this_terms;
longest = i;
}
появляется во внутреннем цикле. Подъем, выходящий из внутреннего контура в версии C, сокращает время его работы до 0,27 секунды, делая коэффициент ~ 1,4.
Ответ 3
Списки Haskell основаны на куче, тогда как ваш C-код чрезвычайно плотный и вообще не использует кучу. Вам нужно реорганизовать, чтобы удалить зависимость от списков.
Ответ 4
Сравнение может слишком много повторять длину последовательности. Это моя лучшая версия:
type I = Integer
data P = P {-# UNPACK #-} !Int {-# UNPACK #-} !I deriving (Eq,Ord,Show)
searchTo = 1000000
nextNumber :: I -> I
nextNumber n = case quotRem n 2 of
(n2,0) -> n2
_ -> 3*n+1
sequenceLength :: I -> Int
sequenceLength x = count x 1 where
count 1 acc = acc
count n acc = count (nextNumber n) (succ acc)
longestSequence = maximum . map (\i -> P (sequenceLength i) i) $ [1..searchTo]
main = putStrLn $ show $ longestSequence
Ответ и время медленнее, чем C, но он использует произвольную точность Integer:
ghc -O2 --make euler14-fgij.hs
time ./euler14-fgij
P 525 837799
real 0m3.235s
user 0m3.184s
sys 0m0.015s
Ответ 5
Даже если я немного опоздал, вот мой, я удалил зависимость от списков, и это решение тоже не использует кучу.
{-# LANGUAGE BangPatterns #-}
-- Compiled with ghc -O2 -fvia-C -optc-O3 -Wall euler.hs
module Main (main) where
searchTo :: Int
searchTo = 1000000
nextNumber :: Int -> Int
nextNumber n = case n `divMod` 2 of
(k,0) -> k
_ -> 3*n + 1
sequenceLength :: Int -> Int
sequenceLength n = sl 1 n where
sl k 1 = k
sl k x = sl (k + 1) (nextNumber x)
longestSequence :: Int
longestSequence = testValues 1 0 0 where
testValues number !longest !longestNum
| number > searchTo = longestNum
| otherwise = testValues (number + 1) longest' longestNum' where
nlength = sequenceLength number
(longest',longestNum') = if nlength > longest
then (nlength,number)
else (longest,longestNum)
main :: IO ()
main = print longestSequence
Я скомпилировал этот фрагмент с ghc -O2 -fvia-C -optc-O3 -Wall euler.hs
, и он работает через 5 секунд по сравнению с 80 из начала реализации. Он не использует Integer
, но поскольку я на 64-битной машине, результаты могут быть обмануты.
В этом случае компилятор может распаковать все Int, что приведет к действительно быстрому коду. Он работает быстрее, чем все другие решения, которые я видел до сих пор, но C все еще быстрее.