Почему этот простой алгоритм haskell настолько медленный?
Предупреждение о спойлере: это связано с Проблема 14 из Project Euler.
Следующий код занимает около 15 секунд для запуска. У меня есть нерекурсивное решение Java, которое работает за 1 секунду. Я думаю, я должен был бы получить этот код намного ближе к этому.
import Data.List
collatz a 1 = a
collatz a x
| even x = collatz (a + 1) (x `div` 2)
| otherwise = collatz (a + 1) (3 * x + 1)
main = do
print ((foldl1' max) . map (collatz 1) $ [1..1000000])
Я профилировал с помощью +RHS -p
и заметил, что выделенная память большая и растет по мере роста ввода. Для n = 100,000
выделяется 1gb (!), Для n = 1,000,000
13gb (!!).
Опять же, -sstderr
показывает, что, хотя было выделено большое количество байтов, общий объем использования памяти составлял 1 мб, а производительность составляла 95% +, поэтому, возможно, 13 ГБ является красной селедкой.
Я могу представить несколько возможностей:
-
Что-то не так строго, как должно быть. Я уже открыл
foldl1'
, но, может быть, мне нужно сделать больше? Можно ли пометить collatz
как строго (это даже имеет смысл?)
-
collatz
не оптимизируется по вызову. Я думаю, это должно быть, но не
знать способ подтверждения.
-
Компилятор не делает некоторые оптимизации, я думаю, что это должно быть - например
только два результата collatz
должны быть в памяти в любой момент времени (макс и ток)
Любые предложения?
Это почти дубликат Почему это выражение Haskell так медленно?, хотя я буду замечать, что быстрое решение Java не должно выполнять никаких заметок. Есть ли способы ускорить это, не прибегая к этому?
Для справки, вот мой профилирующий вывод:
Wed Dec 28 09:33 2011 Time and Allocation Profiling Report (Final)
scratch +RTS -p -hc -RTS
total time = 5.12 secs (256 ticks @ 20 ms)
total alloc = 13,229,705,716 bytes (excludes profiling overheads)
COST CENTRE MODULE %time %alloc
collatz Main 99.6 99.4
individual inherited
COST CENTRE MODULE no. entries %time %alloc %time %alloc
MAIN MAIN 1 0 0.0 0.0 100.0 100.0
CAF Main 208 10 0.0 0.0 100.0 100.0
collatz Main 215 1 0.0 0.0 0.0 0.0
main Main 214 1 0.4 0.6 100.0 100.0
collatz Main 216 0 99.6 99.4 99.6 99.4
CAF GHC.IO.Handle.FD 145 2 0.0 0.0 0.0 0.0
CAF System.Posix.Internals 144 1 0.0 0.0 0.0 0.0
CAF GHC.Conc 128 1 0.0 0.0 0.0 0.0
CAF GHC.IO.Handle.Internals 119 1 0.0 0.0 0.0 0.0
CAF GHC.IO.Encoding.Iconv 113 5 0.0 0.0 0.0 0.0
И -sstderr:
./scratch +RTS -sstderr
525
21,085,474,908 bytes allocated in the heap
87,799,504 bytes copied during GC
9,420 bytes maximum residency (1 sample(s))
12,824 bytes maximum slop
1 MB total memory in use (0 MB lost due to fragmentation)
Generation 0: 40219 collections, 0 parallel, 0.40s, 0.51s elapsed
Generation 1: 1 collections, 0 parallel, 0.00s, 0.00s elapsed
INIT time 0.00s ( 0.00s elapsed)
MUT time 35.38s ( 36.37s elapsed)
GC time 0.40s ( 0.51s elapsed)
RP time 0.00s ( 0.00s elapsed) PROF time 0.00s ( 0.00s elapsed)
EXIT time 0.00s ( 0.00s elapsed)
Total time 35.79s ( 36.88s elapsed) %GC time 1.1% (1.4% elapsed) Alloc rate 595,897,095 bytes per MUT second
Productivity 98.9% of total user, 95.9% of total elapsed
И решение Java (не мое, снятое с форумов Project Euler с удалением memoization):
public class Collatz {
public int getChainLength( int n )
{
long num = n;
int count = 1;
while( num > 1 )
{
num = ( num%2 == 0 ) ? num >> 1 : 3*num+1;
count++;
}
return count;
}
public static void main(String[] args) {
Collatz obj = new Collatz();
long tic = System.currentTimeMillis();
int max = 0, len = 0, index = 0;
for( int i = 3; i < 1000000; i++ )
{
len = obj.getChainLength(i);
if( len > max )
{
max = len;
index = i;
}
}
long toc = System.currentTimeMillis();
System.out.println(toc-tic);
System.out.println( "Index: " + index + ", length = " + max );
}
}
Ответы
Ответ 1
Сначала я подумал, что вы должны попробовать поставить восклицательный знак перед a в collatz
:
collatz !a 1 = a
collatz !a x
| even x = collatz (a + 1) (x `div` 2)
| otherwise = collatz (a + 1) (3 * x + 1)
(Для этого вам нужно поместить {-# LANGUAGE BangPatterns #-}
в начало исходного файла).
Мои рассуждения были следующими: Проблема в том, что вы создаете массивный thunk в первом аргументе для collatz: он начинается с 1
, а затем становится 1 + 1
, а затем становится (1 + 1) + 1
,... все без принуждения. Этот bang pattern заставляет первый аргумент collatz
быть принудительным всякий раз, когда делается вызов, поэтому он начинается с 1, а затем становится 2, и т.д., не создавая большой неоценимый тон: он просто остается целым числом.
Обратите внимание, что шаблон взлома является только сокращением для использования seq
; в этом случае мы могли бы переписать collatz
следующим образом:
collatz a _ | seq a False = undefined
collatz a 1 = a
collatz a x
| even x = collatz (a + 1) (x `div` 2)
| otherwise = collatz (a + 1) (3 * x + 1)
Трюк здесь заключается в том, чтобы заставить a в охраннике, который тогда всегда оценивается как False (и поэтому тело не имеет значения). Затем оценка продолжается в следующем случае, уже оцененная. Тем не менее, картина взлома более ясна.
К сожалению, при компиляции с -O2
это не работает быстрее, чем оригинал! Что еще мы можем попробовать? Ну, мы можем только предположить, что два числа никогда не переполняют целое число в единице размера и дают collatz
аннотацию этого типа:
collatz :: Int -> Int -> Int
Мы оставим шаблон взлома, поскольку нам все равно следует избегать наращивания громкости, даже если они не являются корнем проблемы производительности. Это сокращает время до 8,5 секунд на моем (медленном) компьютере.
Следующий шаг - попытаться приблизиться к решению Java. Первое, что нужно понять, это то, что в Haskell div
ведет себя более математически корректно по отношению к отрицательным целым числам, но медленнее, чем "нормальное" C-деление, которое в Haskell называется quot
. Замена div
на quot
привела к сокращению времени выполнения до 5,2 секунд и замене x `quot` 2
на x `shiftR` 1
(импорт Data.Bits) в соответствии с решением Java, которое сократило его до 4,9 секунд.
Это примерно так же мало, как я могу получить его сейчас, но я думаю, что это довольно хороший результат; так как ваш компьютер быстрее моего, он, надеюсь, будет еще ближе к решению Java.
Вот окончательный код (я немного потрудился по пути):
{-# LANGUAGE BangPatterns #-}
import Data.Bits
import Data.List
collatz :: Int -> Int
collatz = collatz' 1
where collatz' :: Int -> Int -> Int
collatz' !a 1 = a
collatz' !a x
| even x = collatz' (a + 1) (x `shiftR` 1)
| otherwise = collatz' (a + 1) (3 * x + 1)
main :: IO ()
main = print . foldl1' max . map collatz $ [1..1000000]
Глядя на ядро GHC для этой программы (ghc-core
), я думаю, что это, вероятно, так же хорошо, как и получается; цикл collatz
использует unboxed integers, а остальная часть программы выглядит нормально. Единственное улучшение, о котором я могу думать, это исключить бокс из итерации map collatz [1..1000000]
.
Кстати, не беспокойтесь о цифре "total alloc"; это общая память, выделенная за время жизни программы, и она никогда не уменьшается даже тогда, когда GC восстанавливает эту память. Цифры с несколькими терабайтами являются общими.
Ответ 2
Вы можете потерять список и шаблоны ударов и по-прежнему получать ту же производительность, используя стек.
import Data.List
import Data.Bits
coll :: Int -> Int
coll 0 = 0
coll 1 = 1
coll 2 = 2
coll n =
let a = coll (n - 1)
collatz a 1 = a
collatz a x
| even x = collatz (a + 1) (x `shiftR` 1)
| otherwise = collatz (a + 1) (3 * x + 1)
in max a (collatz 1 n)
main = do
print $ coll 100000
Одна из проблем заключается в том, что вам придется увеличить размер стека для больших входов, например 1_000_000.
обновление:
Вот хвостовая рекурсивная версия, которая не страдает от проблемы.
import Data.Word
collatz :: Word -> Word -> (Word, Word)
collatz a x
| x == 1 = (a,x)
| even x = collatz (a + 1) (x `quot` 2)
| otherwise = collatz (a + 1) (3 * x + 1)
coll :: Word -> Word
coll n = collTail 0 n
where
collTail m 1 = m
collTail m n = collTail (max (fst $ collatz 1 n) m) (n-1)
Обратите внимание на использование Word
вместо Int
. Это отличает производительность. Вы все равно можете использовать шаблоны ударов, если хотите, и это почти удвоит производительность.
Ответ 3
Одна вещь, которую я нашел, сделала удивительную разницу в этой проблеме. Я придерживался прямого рекуррентного отношения, а не складывался, вы должны простить выражение, подсчитывая его. Переписывание
collatz n = if even n then n `div` 2 else 3 * n + 1
а
collatz n = case n `divMod` 2 of
(n', 0) -> n'
_ -> 3 * n + 1
потребовалось 1,2 секунды от времени выполнения моей программы в системе с 2,8 ГГц Athlon II X4 430 CPU. Моя начальная более быстрая версия (через 2.3 секунды после использования divMod):
{-# LANGUAGE BangPatterns #-}
import Data.List
import Data.Ord
collatzChainLen :: Int -> Int
collatzChainLen n = collatzChainLen' n 1
where collatzChainLen' n !l
| n == 1 = l
| otherwise = collatzChainLen' (collatz n) (l + 1)
collatz:: Int -> Int
collatz n = case n `divMod` 2 of
(n', 0) -> n'
_ -> 3 * n + 1
pairMap :: (a -> b) -> [a] -> [(a, b)]
pairMap f xs = [(x, f x) | x <- xs]
main :: IO ()
main = print $ fst (maximumBy (comparing snd) (pairMap collatzChainLen [1..999999]))
Возможно, более идиоматическая версия Haskell запускается примерно за 9,7 секунды (8.5 с divMod); он идентичен, кроме
collatzChainLen :: Int -> Int
collatzChainLen n = 1 + (length . takeWhile (/= 1) . (iterate collatz)) n
Использование Data.List.Stream должно позволить слияние потоков, которое сделает эту версию более похожей на нее с явным накоплением, но я не могу найти пакет libbc * Ubuntu, который имеет Data.List.Stream, поэтому я еще не может проверить это.