Проблема производительности с проблемой Эйлера и рекурсией на типах Int64

В настоящее время я изучаю Haskell, используя в качестве игровой площадки проект «Проблемы Эйлера». Я был поражен тем, насколько медленными оказались мои программы на Haskell по сравнению с аналогичными программами, написанными на других языках. Мне интересно, предвидел ли я что-то или это те потери производительности, которых следует ожидать при использовании Haskell.

Следующая программа вдохновлена ​​Задачей 331, но я изменил ее перед публикацией, чтобы ничего не портить другим людям. Он вычисляет длину дуги дискретного круга, нарисованного на сетке 2 ^ 30 x 2 ^ 30. Это простая хвостовая рекурсивная реализация, и я удостоверяюсь, что обновление переменной накопления, отслеживающей длину дуги, строгое. Тем не менее, это занимает почти полторы минуты (скомпилировано с флагом -O с помощью ghc).

import Data.Int

arcLength :: Int64->Int64
arcLength n = arcLength' 0 (n-1) 0 0 where
    arcLength' x y norm2 acc
        | x > y = acc
        | norm2 < 0 = arcLength' (x + 1) y (norm2 + 2*x +1) acc
        | norm2 > 2*(n-1) = arcLength' (x - 1) (y-1) (norm2 - 2*(x + y) + 2) acc
        | otherwise = arcLength' (x + 1) y (norm2 + 2*x + 1) $! (acc + 1)

main = print $ arcLength (2^30)

Вот соответствующая реализация на Java. Это займет около 4,5 секунд.

public class ArcLength {
public static void main(String args[]) {
    long n = 1 << 30;
    long x = 0;
    long y = n-1;
    long acc = 0;
    long norm2 = 0;
    long time = System.currentTimeMillis();

    while(x <= y) {
        if (norm2 < 0) {
            norm2 += 2*x + 1;
            x++;
        } else if (norm2 > 2*(n-1)) {
            norm2 += 2 - 2*(x+y);
            x--;
            y--;
        } else {
            norm2 += 2*x + 1;
            x++;
            acc++;
        }
    }

    time = System.currentTimeMillis() - time;
    System.err.println(acc);
    System.err.println(time);
}

}

РЕДАКТИРОВАТЬ: после обсуждений в комментариях я внес некоторые изменения в код Haskell и провел несколько тестов производительности. Сначала я изменил n на 2 ^ 29, чтобы избежать переполнения. Затем я попробовал 6 разных версий: с Int64 или Int и с челкой перед norm2 или обоими и norm2 и acc в объявлении arcLength' x y !norm2 !acc. Все скомпилированы с

ghc -O3 -prof -rtsopts -fforce-recomp -XBangPatterns arctest.hs

Вот результаты:

(Int !norm2 !acc)
total time  =        3.00 secs   (150 ticks @ 20 ms)
total alloc =       2,892 bytes  (excludes profiling overheads)

(Int norm2 !acc)
total time  =        3.56 secs   (178 ticks @ 20 ms)
total alloc =       2,892 bytes  (excludes profiling overheads)

(Int norm2 acc)
total time  =        3.56 secs   (178 ticks @ 20 ms)
total alloc =       2,892 bytes  (excludes profiling overheads)

(Int64 norm2 acc)
arctest.exe: out of memory

(Int64 norm2 !acc)
total time  =       48.46 secs   (2423 ticks @ 20 ms)
total alloc = 26,246,173,228 bytes  (excludes profiling overheads)

(Int64 !norm2 !acc)
total time  =       31.46 secs   (1573 ticks @ 20 ms)
total alloc =       3,032 bytes  (excludes profiling overheads)

Я использую GHC 7.0.2 под 64-битной Windows 7 (двоичный дистрибутив платформы Haskell). Судя по комментариям, проблема не возникает при компиляции под другие конфигурации. Это заставляет меня думать, что тип Int64 сломан в выпуске Windows.


person dbergh    schedule 30.04.2011    source источник
comment
Вы пробовали узор челки? arcLength' x y !norm2 !acc? norm2 и acc не всегда передаются строго, потому что они могут не понадобиться при выполнении первой ветви. Кстати, на моей машине это занимает всего 6 секунд.   -  person fuz    schedule 30.04.2011
comment
Как правило, Haskell может быть одним из самых быстрых языков. Вероятно, в вашем коде Haskell есть что-то, что ухудшает сложность или не подходит для оптимизации GHC.   -  person    schedule 30.04.2011
comment
-O2 - типичный флаг для оптимизации GHC. -O не делает слишком много, iirc.   -  person Thomas M. DuBuisson    schedule 30.04.2011
comment
Комментарии, кажется, предполагают, что это ошибка, связанная с GHC 7.0.2 и 64-битным GMP (откуда берется тип Int64) в 32-битной Windows. Можете ли вы обновить libgmp, обновить GHC до 7.0.3 или протестировать на 64-битной Windows?   -  person Don Stewart    schedule 30.04.2011
comment
У меня такое же поведение на GHC 7.0.3. Я также работаю на 64-битной Windows. Но я подозреваю, что двоичный дистрибутив платформы Haskell 32-битный. 64-скачиваний не было.   -  person dbergh    schedule 30.04.2011


Ответы (6)


Хм, я установил новую платформу Haskell с 7.0.3 и получил примерно следующее ядро ​​для вашей программы (-ddump-simpl):

Main.$warcLength' =
  \ (ww_s1my :: GHC.Prim.Int64#) (ww1_s1mC :: GHC.Prim.Int64#)
    (ww2_s1mG :: GHC.Prim.Int64#) (ww3_s1mK :: GHC.Prim.Int64#) ->
    case {__pkg_ccall ghc-prim hs_gtInt64 [...]
           ww_s1my ww1_s1mC GHC.Prim.realWorld#
[...]

Итак, GHC понял, что может распаковывать ваши целые числа, и это хорошо. Но этот вызов hs_getInt64 подозрительно похож на вызов C. Глядя на вывод ассемблера (-ddump-asm), мы видим что-то вроде:

pushl %eax
movl 76(%esp),%eax
pushl %eax
call _hs_gtInt64
addl $16,%esp

Это очень похоже на то, как каждая операция Int64 get превращается в полноценный вызов C в бэкэнде. Что, очевидно, медленно.

Исходный код из GHC.IntWord64, похоже, подтверждает следующее: в 32-битной сборке (например, той, которая сейчас поставляется с платформой), у вас будет только эмуляция через интерфейс FFI.

person Peter Wortmann    schedule 30.04.2011
comment
Верно. На 32-битной машине Int64 реализуется через библиотеку C GMP. Это будет иметь некоторые накладные расходы на производительность. На 64-битной машине таких проблем нет. - person Don Stewart; 30.04.2011
comment
Думаю, на этом все решено. Спасибо! Я также полагаю, что 64-битной машины недостаточно, если у вас Windows, поскольку нет 64-битной GHC для Windows (hackage.haskell.org/trac/ghc/wiki/WindowsGhc). - person dbergh; 30.04.2011
comment
И вы получите такое же ухудшение, используя вместо этого Integer (который также реализован как вызов C в libgmp). - person Don Stewart; 30.04.2011

Хм, это интересно. Итак, я просто скомпилировал обе ваши программы и попробовал их:

% java -version                                                                                          
java version "1.6.0_18"
OpenJDK Runtime Environment (IcedTea6 1.8.7) (6b18-1.8.7-2~squeeze1)
OpenJDK 64-Bit Server VM (build 14.0-b16, mixed mode)
% javac ArcLength.java                                                                                   
% java ArcLength                                                                                         
843298604
6630

Итак, около 6,6 секунды для решения Java. Далее идет ghc с некоторой оптимизацией:

% ghc --version                                                                                          
The Glorious Glasgow Haskell Compilation System, version 6.12.1
% ghc --make -O arc.hs
% time ./arc                                                                                             
843298604
./arc  12.68s user 0.04s system 99% cpu 12.718 total

Менее 13 секунд для ghc -O

Пробуем с дальнейшей оптимизацией:

% ghc --make -O3
% time ./arc                                                                                             [13:16]
843298604
./arc  5.75s user 0.00s system 99% cpu 5.754 total

С дополнительными флагами оптимизации решение haskell заняло менее 6 секунд

Было бы интересно узнать, какую версию компилятора вы используете.

person monk    schedule 30.04.2011
comment
Мне то же самое. Дела пошли быстрее, когда я изменил Int64 на Int и добавил явную подпись типа внутреннему исполнителю. Я на x64, ghc 7.0.3 - person fuz; 30.04.2011
comment
Я использую ghc под окнами. ghc --version Система компиляции Glorious Glasgow Haskell, версия 7.0.2 - person dbergh; 30.04.2011
comment
dbergh: x86 или x64? Какая скорость процессора? - person fuz; 30.04.2011
comment
@FUZxxl Не должен ли компилятор автоматически делать вывод, что тип x, y и т. Д. Совпадает с типом для n? Использование Int вызывает переполнение, по крайней мере, если Int 32-битный. - person dbergh; 30.04.2011
comment
@dbergh: Я использую x64, поэтому Int - это 64-битный тип. О типовых сигнатурах: я взглянул на представление ядра и был немного шокирован, когда увидел, что вокруг все еще скрываются Integers. Но теперь я думаю, что они возникают откуда-то еще. Если Int переполняется, почему бы не попробовать Integer? Встроенный тип Integer ghc работает быстро, и вам не нужно беспокоиться о границах. - person fuz; 30.04.2011
comment
Intel (R) Core (TM) 2 Duo CPU E8400 @ 3,00 ГГц. Я также пробовал это на более медленной системе Linux (Intel Atom), используя ghc -O3 версии 6.12.1. В этой конфигурации это занимает 5 мин. 20 сек. - person dbergh; 30.04.2011
comment
Также процессор не x64 имеет 64-битные регистры данных. В этом случае Int64 оказывается значительно быстрее, чем Integer (я убил процесс через 4 минуты, используя Integer). В любом случае это не объясняет огромной разницы во времени вычислений. @Monk Вы действительно скопировали код, не изменяя его? - person dbergh; 30.04.2011
comment
Я вижу ту же медлительность с Int64 при компиляции 32-битной версией GHC 7.0.2 и такое же время ‹5 секунд при компиляции с версией x64 (хотя и 7.0.3). Параметр Btw -fllvm, похоже, ускоряет 32-битную версию этого (не уверен, как он будет вести себя с x64, поскольку он игнорирует -fllvm в x64 GHC на MacOS) - person Ed'ka; 30.04.2011
comment
Я скопировал код haskell, опубликованный @dbergh, и он закончился в [real 0m4.322s, user 0m4.320s, sys 0m0.000s] Edit: также скомпилирован с -O3 - person Palmik; 30.04.2011
comment
Хм, при компиляции без -O3 я убил его через минуту или около того, поэтому оптимизация, выполненная с помощью -O3, здесь ОГРОМНАЯ. - person Palmik; 30.04.2011
comment
@dbergh Я действительно просто скопировал код, не меняя его. Мой первоначальный тест проводился с бинарным файлом x64 на Linux x64. Я также запускаю окна x64 на своем рабочем столе, поэтому я просто попробовал ваш код там. Код haskell действительно работает намного медленнее, возможно, это снижение производительности связано с тем, что вы не можете создать двоичный файл x64 из ghc для Windows? Обновлю свой ответ через секунду. - person monk; 30.04.2011
comment
@monk: IIRC нет. Это может быть проблемой. - person fuz; 30.04.2011
comment
@ Ed'ka: На моей платформе -fllvm замедлил с 6 до 7 секунд. - person fuz; 30.04.2011
comment
Но больше нет. После добавления некоторых ударов, llvm заставил его работать со скоростью около 5,5 секунд, что весьма впечатляет. Ради интереса я преобразовал java-код в C. Он работает за 2,8 секунды при компиляции с -O3 в x64 linux. - person fuz; 30.04.2011
comment
Хммм, меня немного разочаровывает то, что случайное загромождение кода восклицательными знаками оказывает такое большое влияние. Но мне кажется, что реальная проблема заключается в том, что реализация 64-битной арифметики плохая ... По моим подсчетам, использование 64-битных целых чисел почти увеличивает время выполнения в 10 раз. - person dbergh; 30.04.2011
comment
@dbergh: не беспорядочно загромождайте. Обратите внимание на оценку (строгий / ленивый) и укажите, какое поведение вы хотите. - person Don Stewart; 30.04.2011
comment
@ Дон Стюарт. Хорошо, что ты думаешь в этом случае хорошего? Я обнаружил, что в этом случае переменная acc очевидна. После некоторых размышлений norm2 тоже показался хорошим кандидатом, но я думаю, что компилятор должен найти его сам. Переменные x, y и n я бы сказал однозначно нет. Я на самом деле пробовал !n, это замедляло работу (хотя я не понимаю почему), а остальные никак не повлияли. - person dbergh; 30.04.2011
comment
Да, ты прав. acc не тестируется в цикле, поэтому можно предположить, что он ленив. norm2 может быть, а может и не быть, в зависимости от того, как вы пишете циклы (см. Мой ответ). ghc -O2 может видеть строгость всего остального. Это анализ спроса, и у вас, кажется, есть хорошая интуиция. - person Don Stewart; 30.04.2011

В вашем вопросе есть пара интересных моментов.

В первую очередь вы должны использовать -O2. Он просто сделает свою работу лучше (в данном случае выявляет и устраняет лень, которая все еще присутствовала в версии -O).

Во-вторых, ваш Haskell не совсем такой же, как Java (он выполняет разные тесты и ветки). Как и в случае с другими, запуск вашего кода на моем Linux-компьютере приводит к времени выполнения около 6 секунд. Вроде нормально.

Убедитесь, что он такой же, как Java.

Одна идея: давайте сделаем буквальную транскрипцию вашей Java с тем же потоком управления, операциями и типами.

import Data.Bits
import Data.Int

loop :: Int -> Int
loop n = go 0 (n-1) 0 0
    where
        go :: Int -> Int -> Int -> Int -> Int
        go x y acc norm2
            | x <= y        = case () of { _
                | norm2 < 0         -> go (x+1) y     acc     (norm2 + 2*x + 1)
                | norm2 > 2 * (n-1) -> go (x-1) (y-1) acc     (norm2 + 2 - 2 * (x+y))
                | otherwise         -> go (x+1) y     (acc+1) (norm2 + 2*x + 1)
            }
            | otherwise     = acc

main = print $ loop (1 `shiftL` 30)

Загляните в суть

Мы быстро взглянем на ядро, с использованием ghc-core, и оно показывает очень красивый цикл распакованного типа:

main_$s$wgo
  :: Int#
     -> Int#
     -> Int#
     -> Int#
     -> Int#

main_$s$wgo =
  \ (sc_sQa :: Int#)
    (sc1_sQb :: Int#)
    (sc2_sQc :: Int#)
    (sc3_sQd :: Int#) ->
    case <=# sc3_sQd sc2_sQc of _ {
      False -> sc1_sQb;
      True ->
        case <# sc_sQa 0 of _ {
          False ->
            case ># sc_sQa 2147483646 of _ {
              False ->
                main_$s$wgo
                  (+# (+# sc_sQa (*# 2 sc3_sQd)) 1)
                  (+# sc1_sQb 1)
                  sc2_sQc
                      (+# sc3_sQd 1);
              True ->
                main_$s$wgo
                  (-#
                     (+# sc_sQa 2)
                     (*# 2 (+# sc3_sQd sc2_sQc)))
                  sc1_sQb
                  (-# sc2_sQc 1)
                  (-# sc3_sQd 1)
            };
          True ->
            main_$s$wgo
              (+# (+# sc_sQa (*# 2 sc3_sQd)) 1)
              sc1_sQb
              sc2_sQc
              (+# sc3_sQd 1)

то есть все распаковано в регистры. Этот цикл выглядит великолепно!

И работает нормально (Linux / x86-64 / GHC 7.03):

./A  5.95s user 0.01s system 99% cpu 5.980 total

Проверка asm

У нас тоже получается разумная сборка в виде красивого цикла:

Main_mainzuzdszdwgo_info:
        cmpq    %rdi, %r8
        jg      .L8
.L3:
        testq   %r14, %r14
        movq    %r14, %rdx
        js      .L4
        cmpq    $2147483646, %r14
        jle     .L9
.L5:
        leaq    (%rdi,%r8), %r10
        addq    $2, %rdx
        leaq    -1(%rdi), %rdi
        addq    %r10, %r10
        movq    %rdx, %r14
        leaq    -1(%r8), %r8
        subq    %r10, %r14
        jmp     Main_mainzuzdszdwgo_info
.L9:
        leaq    1(%r14,%r8,2), %r14
        addq    $1, %rsi
        leaq    1(%r8), %r8
        jmp     Main_mainzuzdszdwgo_info
.L8:
        movq    %rsi, %rbx
        jmp     *0(%rbp)
.L4:
        leaq    1(%r14,%r8,2), %r14
        leaq    1(%r8), %r8
        jmp     Main_mainzuzdszdwgo_info

Используя серверную часть -fvia-C.

Так что это выглядит нормально!


Мое подозрение, как упоминалось в комментарии выше, связано с версией libgmp, которая у вас есть в 32-битной Windows, генерирующей плохой код для 64-битных int. Сначала попробуйте выполнить обновление до GHC 7.0.3, а затем попробуйте некоторые другие серверные части генератора кода, а затем, если у вас все еще есть проблема с Int64, отправьте отчет об ошибке в GHC trac.

В целом подтверждая, что это действительно затраты на выполнение этих вызовов C в 32-битной эмуляции 64-битных целых чисел, мы можем заменить Int64 на Integer, который реализуется с помощью вызовов GMP на каждой машине, и действительно, время выполнения увеличивается с 3 с до больше минуты.

Урок: используйте 64-битное аппаратное обеспечение, если это возможно.

person Don Stewart    schedule 30.04.2011
comment
Не всегда возможно. Haskell не может скомпилировать 64-битный код под Windows, так что это не всегда вариант. - person fuz; 01.05.2011
comment
Как получить такой чистый основной результат? - person fuz; 01.05.2011
comment
Используя ghc-core, который вы можете получить здесь: hackage.haskell.org/package/ghc-core - person Don Stewart; 01.05.2011

Нормальный флаг оптимизации для соответствующего кода производительности - -O2. То, что вы использовали, -O, дает очень мало. -O3 не делает ничего больше, чем -O2 - в него даже включались экспериментальные «оптимизации», которые часто заметно замедляли работу программ.

С -O2 я получаю производительность, конкурентоспособную с Java:

tommd@Mavlo:Test$ uname -r -m
2.6.37 x86_64
tommd@Mavlo:Test$ ghc --version
The Glorious Glasgow Haskell Compilation System, version 7.0.3

tommd@Mavlo:Test$ ghc -O2 so.hs
[1 of 1] Compiling Main             ( so.hs, so.o )
Linking so ...
tommd@Mavlo:Test$ time ./so
843298604

real    0m4.948s
user    0m4.896s
sys     0m0.000s

А Java примерно на 1 секунду быстрее (20%):

tommd@Mavlo:Test$ time java ArcLength
843298604
3880

real    0m3.961s
user    0m3.936s
sys     0m0.024s

Но что интересно в GHC, так это то, что у него много разных бэкендов. По умолчанию он использует генератор собственного кода (NCG), который мы рассчитали выше. Также есть бэкэнд LLVM, который часто работает лучше ... но не здесь:

tommd@Mavlo:Test$ ghc -O2 so.hs -fllvm -fforce-recomp
[1 of 1] Compiling Main             ( so.hs, so.o )
Linking so ...
tommd@Mavlo:Test$ time ./so
843298604

real    0m5.973s
user    0m5.968s
sys     0m0.000s

Но, как упоминал FUZxxl в комментариях, LLVM работает намного лучше, когда вы добавляете несколько аннотаций строгости:

$ ghc -O2 -fllvm -fforce-recomp so.hs
[1 of 1] Compiling Main             ( so.hs, so.o )
Linking so ...
tommd@Mavlo:Test$ time ./so
843298604

real    0m4.099s
user    0m4.088s
sys     0m0.000s

Также есть старый генератор via-c, который использует C в качестве промежуточного языка. В этом случае это хорошо:

tommd@Mavlo:Test$ ghc -O2 so.hs -fvia-c -fforce-recomp
[1 of 1] Compiling Main             ( so.hs, so.o )

on the commandline:
    Warning: The -fvia-c flag will be removed in a future GHC release
Linking so ...
ttommd@Mavlo:Test$ ti
tommd@Mavlo:Test$ time ./so
843298604

real    0m3.982s
user    0m3.972s
sys     0m0.000s

Надеюсь, что NCG будет улучшена, чтобы соответствовать переходу-c для этого случая, прежде чем они удалят этот бэкэнд.

person Thomas M. DuBuisson    schedule 30.04.2011
comment
Когда вы добавляете образцы взрыва в параметр norm2 в arcLength', серверная часть llvm выполняет гораздо лучшую работу, потому что norm2 по умолчанию не является строгим. - person fuz; 30.04.2011
comment
Ах! И еще нужно добавить челку в arcLength !n. - person fuz; 30.04.2011

dberg, я чувствую, что все это началось плохо из-за неудачного -O флага. Просто чтобы подчеркнуть точку зрения других, для стандартной компиляции и тестирования, сделайте лайк и вставьте это в свой .bashrc или что-то еще:

alias ggg="ghc --make -O2"
alias gggg="echo 'Glorious Glasgow for Great Good!' && ghc --make -O2 --fforce-recomp"
person applicative    schedule 30.04.2011

Я немного поиграл с кодом, и эта версия, похоже, работает быстрее, чем версия Java на моем ноутбуке (3,55 с против 4,63 с):

{-# LANGUAGE BangPatterns #-}

arcLength :: Int->Int
arcLength n = arcLength' 0 (n-1) 0 0 where
    arcLength' :: Int -> Int -> Int -> Int -> Int
    arcLength' !x !y !norm2 !acc
        | x > y = acc
        | norm2 > 2*(n-1) = arcLength' (x - 1) (y - 1) (norm2 - 2*(x + y) + 2) acc
        | norm2 < 0 = arcLength' (succ x) y (norm2 + x*2 + 1) acc
        | otherwise = arcLength' (succ x) y (norm2 + 2*x + 1) (acc + 1)      

main = print $ arcLength (2^30)

:

$ ghc -O2 tmp1.hs -fforce-recomp
[1 of 1] Compiling Main             ( tmp1.hs, tmp1.o )
Linking tmp1 ...

$ time ./tmp1
843298604

real    0m3.553s
user    0m3.539s
sys 0m0.006s
person Ed'ka    schedule 30.04.2011
comment
Я скомпилировал версию Java с помощью gcj на своей машине, и она работает за 3,5 секунды, в отличие от haskell, которая выполняется за 6 секунд. JVM замедляет код. - person fuz; 01.05.2011
comment
@FUZxxl Я не могу попробовать gcj, но версия c ++ (gcc -O3) работает здесь в 2.1 с (по сравнению с моим ghc 3.5 с - не так уж и плохо). Не уверен, почему вы получаете 6 с моей версии, и не могу объяснить, почему изменение x+1 на succ x имеет здесь такую ​​большую разницу (5,3 с против 3,6). - person Ed'ka; 01.05.2011
comment
Я тоже. Моя машинка моя менее мощная. На моей машине у меня было около 2,8 секунды для простого C и 3,5 секунды для Java. - person fuz; 01.05.2011