Haskell: Почему Int выполняет хуже, чем Word64, и почему моя программа намного медленнее, чем C?

Я читал статью как медленный Haskell в игре с гипотезой Collatz, которая в основном заявляет, что если вы продолжаете умножать три и плюс одно на нечетное число, или разделив четное на два, вы в конечном итоге получите один. Например, 3 → 10 → 5 → 16 → 8 → 4 → 2 → 1.

Программа, приведенная в этой статье, предназначена для вычисления самой длинной последовательности Collatz в заданном диапазоне. Версия C:

#include <stdio.h>

int main(int argc, char **argv) {
   int max_a0 = atoi(argv[1]); 
   int longest = 0, max_len = 0;
   int a0, len;
   unsigned long a;

   for (a0 = 1; a0 <= max_a0; a0++) {
      a = a0;
      len = 0;

      while (a != 1) {
         len++;
         a = ((a%2==0)? a : 3*a+1)/2;
      }

      if (len > max_len) {
         max_len = len;
         longest = a0;
      }
   }
   printf("(%d, %d)\n", max_len, longest);
   return 0;
}

Компиляция с помощью Clang O2 выполняется на моем компьютере на 0,2 с.

Версия Haskell, приведенная в этой статье, генерирует всю последовательность как список явно, а затем вычисляет длину промежуточного списка. Он в 10 раз медленнее, чем версия C. Однако, поскольку автор использовал LLVM в качестве бэкэнд, который я не установил, я не смог воспроизвести это. Используя GHC 7.8 и бэкэнд по умолчанию, он запускает 10 секунд на моем Mac, что на 50 раз медленнее, чем версия C.

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

collatzNext :: Int -> Int
collatzNext a
  | even a    = a `div` 2
  | otherwise = (3 * a + 1) `div` 2

collatzLen :: Int -> Int
collatzLen n = collatzIter n 0
  where
    collatzIter 1 len = len
    collatzIter n len = collatzIter (collatzNext n) (len + 1)

main = do
  print $ maximum $ [collatzLen x | x <- [1..1000000]]

Скомпилированный с GHC 7.8 и O2, он работает в течение 2 с, меньше на 10 раз, чем версия C.

Интересно, что когда я изменил Int в аннотации типа на Word, он потратил только 1, 2 раза быстрее!

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

Мои вопросы:

  • Почему версия Word работает быстрее по сравнению с Int one?
  • Почему эта программа Haskell настолько медленная, что в C?

Ответы

Ответ 1

Производительность этой программы зависит от нескольких факторов. Если мы получим все правильно, производительность будет такой же, как и в программе C. Просматривая эти факторы:

1. Использование и сравнение правильных размеров слова

Отправленный фрагмент кода C не совсем прав; он использует 32-разрядные целые числа на всех архитектурах, а Haskell Int -s - 64 бит на 64-битной машине. Прежде всего, мы должны обязательно использовать один и тот же размер слова в обеих программах.

Кроме того, мы всегда должны использовать встроенные интегральные типы в нашем коде Haskell. Поэтому, если мы находимся на 64-битной системе, мы должны использовать 64-разрядные номера и избегать Int32 -s и Word32 -s, если только для них не существует конкретной потребности. Это связано с тем, что операции с неродными целыми числами в основном реализуются как внешние вызовы, а не примитивы, поэтому они значительно медленнее.

2. Подраздел в collatzNext

div медленнее, чем quot для Int, потому что div обрабатывает отрицательные числа по-другому. Если мы используем div и переключаемся на Word, программа становится быстрее, потому что div совпадает с quot для Word. quot с Int работает так же хорошо. Тем не менее, это все еще не так быстро, как C. Мы можем разделить на два путем смещения битов вправо. По какой-то причине даже LLVM не делает это конкретное сокращение силы в этом примере, поэтому мы лучше сделаем это вручную, заменив quot n 2 на shiftR n 1.

3. Проверка равномерности

Самый быстрый способ проверить это - проверить младший значащий бит. LLVM может оптимизировать even к этому, в то время как родной код не может. Итак, если мы находимся на родном кодегене, even n может быть заменен на n .&. 1 == 0, и это дает хороший прирост производительности.

Однако, я нашел что-то вроде ошибки производительности с GHC 7.10. Здесь мы не получаем встроенный even для Word, который разрушает производительность (вызов функции с выделенным кучей Word в самой горячей части кода делает это). Поэтому здесь мы должны использовать rem n 2 == 0 или n .&. 1 == 0 вместо even. Тем не менее, even для Int становится тонким.

4. Слияние списков в collatzLen

Это решающий фактор. Связанный пост в блоге немного устарел относительно этого. GHC 7.8 не может слить здесь, но 7.10 может. Это означает, что с GHC 7.10 и LLVM мы можем удобно получить C-подобную производительность без существенного изменения исходного кода.

collatzNext a = (if even a then a else 3*a+1) `quot` 2
collatzLen a0 = length $ takeWhile (/= 1) $ iterate collatzNext a0
maxColLen n   = maximum $ map collatzLen [1..n]

main = do
    [n] <- getArgs
    print $ maxColLen (read n :: Int) 

С ghc-7.10.1 -O2 -fllvm и n = 10000000 указанная выше программа работает в 2,8 секунды, а эквивалентная программа C работает в 2,4 секунды. Если я скомпилирую один и тот же код без LLVM, вместо этого я получаю 12.4 вторую рабочую среду. Это замедление полностью связано с отсутствием оптимизации на even. Если мы используем a .&. 1 == 0, то замедление исчезает.

5. Слияние списков при вычислении максимальной длины

Даже GHC 7.10 не может этого сделать, поэтому мы должны прибегнуть к ручному написанию.

collatzNext a = (if a .&. 1 == 0 then a else 3*a+1) `shiftR` 1
collatzLen    = length . takeWhile (/= 1) . iterate collatzNext

maxCol :: Int -> Int
maxCol = go 1 1 where
  go ml i n | i > n = ml
  go ml i n = go (max ml (collatzLen i)) (i + 1) n

main = do
  [n] <- getArgs
  print $ maxCol (read n :: Int)

Теперь, для ghc-7.10.1 -O2 -fllvm и n = 10000000, приведенный выше код работает в 2.1 секунд, а программа C работает в 2,4 секунды. Если мы хотим добиться такой же производительности без LLVM и GHC 7.10, нам просто нужно вручную применить важные недостающие оптимизации:

collatzLen :: Int -> Int
collatzLen = go 0 where
  go l 1 = l
  go l n | n .&. 1 == 0 = go (l + 1) (shiftR n 1)
         | otherwise    = go (l + 1) (shiftR (3 * n + 1) 1)

maxCol :: Int -> Int
maxCol = go 1 1 where
  go ml i n | i > n = ml
  go ml i n = go (max ml (collatzLen i)) (i + 1) n

main = do
  [n] <- getArgs
  print $ maxCol (read n :: Int)

Теперь, с ghc-7.8.4 -O2 и n = 10000000, наш код работает в 2.6 секунд.