Анализ медленной производительности программы Haskell

Я пытался решить головоломку "Слово Nubmers" ITA Software, используя подход грубой силы. Похоже, моя версия Haskell более чем в 10 раз медленнее, чем версия С#/С++.

Ответ

Благодаря Брайан О'Салливан ответ, я смог "исправить" свою программу до приемлемой производительности. Вы можете прочитать его код, который намного чище, чем мой. Здесь я расскажу о ключевых моментах.

  • Int - Int64 в Linux GHC x64. Если вы не unsafeCoerce, вы должны просто использовать Int. Это избавит вас от необходимости fromIntegral. Выполнение Int64 в Windows 32-бит GHC - это просто darn медленно, избегайте этого. (На самом деле это не ошибка GHC. Как уже упоминалось в моем сообщении в блоге ниже, 64-битные целые числа в 32-разрядных программах в целом медленны (по крайней мере, в Windows))
  • -fllvm или -fvia-C для производительности.
  • Предпочитает quotRem до divMod, quotRem уже достаточно. Это дало мне ускорение на 20%.
  • В общем, предпочитайте Data.Vector Data.Array как "массив"
  • Используйте шаблон обертки-работника либерально.

Вышеупомянутые моменты были достаточными, чтобы дать мне примерно 100% повышение по сравнению с моей оригинальной версией.

В своем сообщении в блоге я подробно описал пошаговый иллюстрированный пример того, как я превратил исходную программу в соответствие с программой Брайана, Здесь также упоминаются другие пункты.

Оригинальный вопрос

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

(Как отмечается в комментариях, я думаю, что я неправильно истолковал проблему, но кто заботится, мы можем сосредоточиться на производительности в другой проблеме)

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

A wordNumber is defined as

wordNumber 1 = "one"
wordNumber 2 = "onetwo"
wordNumber 3 = "onethree"
wordNumber 15 = "onetwothreefourfivesixseveneightnineteneleventwelvethirteenfourteenfifteen"
...

Problem: Find the 51-billion-th letter of (wordNumber Infinity); assume that letter is found at 'wordNumber x', also find 'sum [1..x]'

С императивной точки зрения наивным алгоритмом было бы иметь 2 счетчика, один для суммы чисел и один для суммы длин. Продолжайте считать длину каждого словаNumber и "break", чтобы вернуть результат.

Настоящий подход грубой силы реализован на С# здесь: http://ideone.com/JjCb3. Ответ на мой компьютер занимает около 1,5 минут. Существует также реализация С++, которая выполняется через 45 секунд на моем компьютере.

Затем я применил версию Haskell с грубой силой: http://ideone.com/ngfFq. Он не может завершить расчет за 5 минут на моей машине. (Ирония: у нее больше строк, чем у версии С#)

Вот профиль -p программы Haskell: http://hpaste.org/49934

Вопрос: Как сделать это в сравнении с версией С#? Существуют ли очевидные ошибки, которые я делаю?

(Примечание: я полностью осознаю, что грубое принуждение это не правильное решение этой проблемы. В основном я заинтересован в том, чтобы версия Haskell выполнялась сравнительно с версией С#. Сейчас она по меньшей мере на 5 раз медленнее, поэтому очевидно Мне не хватает чего-то очевидного)

(Примечание 2: Это не похоже на утечку пространства. Программа работает с постоянной памятью (около 2 МБ) на моем компьютере)

(Примечание 3: Я компилирую с помощью `ghc -O2 WordNumber.hs)

Чтобы сделать вопрос более дружественным к читателю, я включаю "суть" двух версий.

// C#
long sumNum = 0;
long sumLen = 0;

long target = 51000000000;
long i = 1;

for (; i < 999999999; i++)
{
    // WordiLength(1)   = 3   "one"
    // WordiLength(101) = 13  "onehundredone"
    long newLength = sumLen + WordiLength(i);
    if (newLength >= target)
        break;

    sumNum += i;
    sumLen = newLength;
}
Console.WriteLine(Wordify(i)[Convert.ToInt32(target - sumLen - 1)]);

-

-- Haskell
-- This has become totally ugly during my squeeze for 
-- performance

-- Tail recursive
-- n-th number (51000000000 in our problem) -> accumulated result -> list of 'zipped' left to try
-- accumulated has the format (sum of numbers, current lengths of the whole chain, the current number)
solve :: Int64 -> (Int64, Int64, Int64) -> [(Int64, Int64)] -> (Int64, Int64, Int64)
solve !n [email protected](!sumNum, !sumLen, !curr) ((!num, !len):xs)
    | sumLen' >= n = (sumNum', sumLen, num)
    | otherwise = solve n (sumNum', sumLen', num) xs
    where
        sumNum' = sumNum + num
        sumLen' = sumLen + len

-- wordLength 1   = 3    "one"
-- wordLength 101 = 13   "onehundredone"
wordLength :: Int64 -> Int64
-- wordLength = ...

solution :: Int64 -> (Int64, Char)
solution !x =
    let (sumNum, sumLen, n) = solve x (0,0,1) (map (\n -> (n, wordLength n)) [1..])
    in (sumNum, (wordify n) !! (fromIntegral $ x - sumLen - 1))

Ответ 1

Я написал текст, содержащий как версию С++ (ваша копия из сообщение Haskell-cafe с исправленной ошибкой) и перевод Haskell.

Обратите внимание, что они структурно почти идентичны. Когда скомпилирован с -fllvm, код Haskell работает примерно на половину скорости кода С++, что довольно хорошо.

Теперь сравните мой код Haskell wordLength с вашим. Вы передаете лишний ненужный параметр, который не нужен (вы, видимо, поняли это при написании кода на С++, который я перевел). Кроме того, большое количество штриховых паттернов вызывает панику; они почти бесполезны.

Ваша функция solve также очень запутана.

  • Вы передаете параметры тремя различными способами: регулярным Int, 3-кортежем и списком! Вау.

  • Эта функция обязательно не очень регулярна в своем поведении, поэтому пока вы ничего не получаете стилистически, используя список для подачи вашего счетчика, вы, вероятно, вынуждаете GHC выделять память. Другими словами, это обе запутывает код и делает его медленнее.

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

  • Только ваш параметр n обрабатывается разумным образом, но вам не нужен шаблон удара.

  • Единственным параметром, который нуждается в шаблоне bang, является sumNum, потому что вы никогда не проверяете его значение до тех пор, пока цикл не завершится. Анализатор строгости GHC будет иметь дело с остальными. Все ваши другие шаблоны ударов в лучшем случае не нужны, неправильные указания в худшем случае.

Ответ 2

Вот два указателя, которые я мог бы найти в кратком исследовании:

  • Обратите внимание, что использование Int64 очень медленное, когда вы используете 32-битную сборку GHC, как и по умолчанию для платформы Haskell. Это также оказалось главным злодеем в предыдущей проблеме производительности (там я расскажу несколько подробнее).

  • По причинам, которые я не совсем понимаю, функция divMod, похоже, не встраивается. В результате числа возвращаются в кучу. При использовании div и mod отдельно, wordLength' выполняется чисто в стеке, как и должно быть.

К сожалению, в настоящее время у меня нет 64-битного GHC, чтобы проверить, достаточно ли этого для решения проблемы.