Подтвердить что ты не робот

Почему факториальный расчет намного быстрее в Haskell, чем в Java

Одна из проблем программирования, с которой я столкнулся, включает вычисление факториалов больших чисел (чисел до 10 ^ 5). Я видел простой код Haskell, который выглядит следующим образом

factorial :: (Eq x, Num x) => x -> x
factorial 0 = 1
factorial a = a * factorial (a - 1)

который неявно обрабатывает огромные числа и как-то работает быстрее даже без кэширования, которое задействовано в коде.

Когда я попытался решить проблему с использованием Java, мне пришлось использовать BigInteger для хранения огромных чисел, а также использовать итеративную версию factorial

public static BigInteger factorialIterative(int n)
{
        if(n == 0 || n == 1) return BigInteger.valueOf(1);
        BigInteger f = BigInteger.valueOf(1);
        for(int i = 1 ; i <= n ;i++)
            f = f.multiply(BigInteger.valueOf(i));
        return f;
}

Вышеприведенный код превысил установленный срок выполнения программы. Я также пробовал кэшированную рекурсивную версию factorial

public static BigInteger factorial(int n)
{
     if(cache[n] != null) 
         return cache[n];
     else if(n == 0) 
         return new BigInteger("1");
     else {
         cache[n] = n* factorial(n - 1);
         return cache[n]; 
     }
}          

который дал мне ошибку из памяти (вероятно, из-за рекурсии).

Мой вопрос: почему языки функционального программирования, такие как Haskell, лучше справляются с этими проблемами, связанными с огромными числами? (несмотря на отсутствие очевидного кэширования). Есть ли способ, чтобы код java выполнялся так же быстро, как код Haskell?

4b9b3361

Ответ 1

Вот связанный с этим вопрос: https://softwareengineering.stackexchange.com/q/149167/26988

Кажется, что в этом конкретном случае вы видите разницу в оптимизации чистой и нечистой функции. В Haskell все функции чисты, если они не выполняют IO (см. Ссылку).

Я думаю, что происходит, что GHC может оптимизировать код лучше из-за гарантии чистоты. Несмотря на отсутствие хвостового вызова, он знает, что никаких побочных эффектов нет (из-за гарантии чистоты), поэтому он может сделать некоторые оптимизации, которые код Java не может (например, автоматическое кэширование и еще что-то вроде @andrew упоминает в своем ответе).

Лучшим решением в Haskell будет использование встроенной функции продукта:

factorial n = product [1..n]

Это может сделать оптимизацию хвостового вызова, потому что это просто итерация. То же самое можно сделать в Java с циклом for, как в вашем примере, но это не принесет пользы от функциональной чистоты.

Edit:

Я предполагал, что устранение хвостового вызова происходит, но, по-видимому, это не так. Здесь исходный ответ для справки (он по-прежнему имеет полезную информацию о том, почему Haskell может быть быстрее, чем Java в некоторых рекурсивных контекстах).

Функциональные языки программирования, такие как Haskell, используют преимущества устранения хвостового вызова.

В большинстве языков программирования рекурсивные вызовы поддерживают стек вызовов. Каждая рекурсивная функция выделяет новый стек, который не очищается до тех пор, пока он не вернется. Например:

call fact()
    call fact()
        call fact()
        cleanup
    cleanup
cleanup

Функциональным языкам, однако, не нужно поддерживать стек. В процедурных языках часто бывает трудно определить, будет ли возвращаемое значение использоваться функцией caling, поэтому его трудно оптимизировать. В FP, однако, возвращаемое значение имеет смысл только тогда, когда рекурсия завершена, поэтому вы можете устранить стек вызовов и в итоге получить что-то вроде этого:

call fact()
call fact()
call fact()
cleanup

Строки call fact() могут произойти в одном стеке стека, потому что возвращаемое значение не требуется в промежуточных вычислениях.

Теперь, чтобы ответить на ваш вопрос, вы можете решить эту проблему различными способами, все из которых направлены на устранение стека вызовов:

  • используйте цикл for вместо рекурсии (обычно это лучший вариант)
  • return void и надеемся, что компилятор выполнит удаление хвоста
  • используйте функцию trampoline (аналогично идее for-loop, но она больше похожа на рекурсию)

Вот некоторые связанные вопросы с примерами для вышеперечисленного:

Примечание:

Не гарантируется, что рекурсивные вызовы будут повторно использовать один и тот же стек стека, поэтому некоторые реализации могут перераспределяться при каждом рекурсивном вызове. Это часто бывает проще и по-прежнему обеспечивает такую ​​же безопасность памяти, как повторное использование кадра стека.

Дополнительные сведения об этом см. в следующих статьях:

Ответ 2

Разница заключается в том, что shachaf сказал, что GHC (по умолчанию) использует GMP для вычислений Integer, которые превышают диапазон Int, и GMP довольно хорошо оптимизирован. Это не имеет ничего общего с чистотой, кэшированием, оптимизацией хвостового вызова или таковым.

Java BigInteger использует более или менее наивные алгоритмы школьной книги. Если вы посмотрите на код multiply (openjdk7), рабочая лошадка

/**
 * Multiplies int arrays x and y to the specified lengths and places
 * the result into z. There will be no leading zeros in the resultant array.
 */
private int[] multiplyToLen(int[] x, int xlen, int[] y, int ylen, int[] z) {
    int xstart = xlen - 1;
    int ystart = ylen - 1;

    if (z == null || z.length < (xlen+ ylen))
        z = new int[xlen+ylen];

    long carry = 0;
    for (int j=ystart, k=ystart+1+xstart; j>=0; j--, k--) {
        long product = (y[j] & LONG_MASK) *
                       (x[xstart] & LONG_MASK) + carry;
        z[k] = (int)product;
        carry = product >>> 32;
    }
    z[xstart] = (int)carry;

    for (int i = xstart-1; i >= 0; i--) {
        carry = 0;
        for (int j=ystart, k=ystart+1+i; j>=0; j--, k--) {
            long product = (y[j] & LONG_MASK) *
                           (x[i] & LONG_MASK) +
                           (z[k] & LONG_MASK) + carry;
            z[k] = (int)product;
            carry = product >>> 32;
        }
        z[i] = (int)carry;
    }
    return z;
}

квадратичное умножение по цифре (цифры, конечно, не основаны на 10). Это не слишком обидно здесь, так как один из факторов всегда однозначный, но указывает, что еще не слишком много работы по оптимизации вычислений BigInteger в Java.

Одна вещь, которая может быть замечена из источника, заключается в том, что в продуктах Java формы smallNumber * largeNumber работают быстрее, чем largeNumber * smallNumber (в частности, если небольшое число однозначно, причем это как первое число означает вторую цикл с вложенным циклом не запускается вообще, поэтому у вас есть все меньше ограничений на управление контуром, а цикл, который выполняется, имеет более простое тело).

Итак, изменяя

f = f.multiply(BigInteger.valueOf(i));

в вашей версии Java на

f = BigInteger.valueOf(i).multiply(f);

дает значительное ускорение (увеличение с аргументом, ~ 2 × для 25000, ~ 2,5 × для 50000, ~ 2,8 × для 100000).

Вычисление по-прежнему намного медленнее, чем комбинация GHC/GMP примерно в 4 раза в тестируемом диапазоне на моем ящике, но реализация GMP лучше оптимизирована.

Если вы делаете вычисления, которые часто умножают два больших числа, алгоритмическая разница между квадратичным умножением BigInteger и GMP, использующим Karatsuba или Toom-Cook, когда факторы достаточно велики (FFT для действительно больших чисел), будет отображаться.

Однако, если умножение - это не все, что вы делаете, если вы распечатываете факториалы, следовательно, преобразовываете их в String, вас поражает тот факт, что метод BigInteger toString отвратительно медленный (это примерно квадратично, так как вычисление факториала в целом является квадратичным по длине результата, вы не получаете более сложной алгоритмической сложности, но вы получаете коэффициент большой на вершине вычисления). Экземпляр Show для Integer намного лучше, O(n * (log n)^x) [не уверен, что x, между 1 и 2], поэтому преобразование результата в String добавляет немного времени вычисления.

Ответ 3

Сначала я хочу указать на два фактора, которые явно не являются причиной разницы в скорости, но были упомянуты, тем не менее, в вопросе и некоторых ответах.

Нет кеширования/заметок

В этом вопросе упоминается кеширование, а в некоторых ответах упоминается memoization. Но факториал не извлекает выгоду из memoization, потому что он рекурсивно называет себя разными аргументами. Поэтому мы никогда не ударили бы запись в уже заполненном кеше, и все кэширование не нужно. Может быть, люди думали о функции фибоначчи здесь?

Для записи Haskell в любом случае не предоставит автоматическую memoization.

Никакой другой умной оптимизации

Как Java, так и программа Haskell выглядят уже довольно оптимальными для меня. Обе программы используют итерационный механизм выбора своих соответствующих языков: Java использует цикл, Haskell использует рекурсию. Обе программы используют стандартный тип для большой целочисленной арифметики.

Во всяком случае, версия Haskell должна быть медленнее, потому что она не является хвостовой рекурсивной, тогда как версия Java использует цикл, который является самой быстрой конструкцией цикла, доступной в Java.

Я не вижу больших возможностей для умных оптимизаций высокого уровня, которые компилятор мог бы сделать для этих программ. Я подозреваю, что наблюдаемая разница в скорости объясняется низкоуровневыми подробностями о том, как реализуются большие целые числа.

Итак, почему версия Haskell быстрее?

Компилятор Haskell имеет встроенную и разумную поддержку Integer. Это, по-видимому, не так с реализациями Java и большим целым классом. Я googled для "BigInteger slow", и результаты показывают, что вопрос действительно должен быть: Почему Java BigInteger настолько медленный? Кажется, что другие большие целые классы быстрее. Я не эксперт по Java, поэтому я не могу ответить на этот вариант вопроса в деталях.

Ответ 4

Я думаю, что разница не имеет ничего общего с оптимизацией хвоста или оптимизацией вообще. Причина, по которой я думаю, заключается в том, что оптимизация может, в лучшем случае, достигать только того, что похоже на вашу итеративную версию Java.

Настоящая причина заключается в том, что IMHO, что Java BigIntegers медленны по сравнению с Haskell's.

Чтобы установить это, я предлагаю 2 эксперимента:

  • Используйте те же алгоритмы, но используйте их долго. (Результатом будет некоторый мусор для более высоких чисел, но вычисления будут выполнены все же.) Здесь версия Java должна быть на одном уровне с Haskell.

  • Используйте более быструю библиотеку больших чисел в версии java. Производительность должна соответственно улучшиться. Там есть обертки для GMP, а также улучшения для больших целых java, таких как здесь. Многозначные показатели производительности, возможные для умножения больших чисел, сообщают.

Ответ 5

Ниже объяснений явно недостаточно. Здесь некоторые слайды, которые объясняют преобразование функции, проходят, когда параметры строки (например, вышеприведенный пример) и не генерируются thunks: http://www.slideshare.net/ilyasergey/static-analyses-and-code-optimizations-in-glasgow-haskell-compiler

В версии Haskell будет выполняться только вычисление, хранящее только предыдущий расчет, и применение следующего, например 6 x 4. В то время как версия Java выполняет кеширование (все исторические значения), управление памятью, GC и т.п..

Он выполняет анализ строгости и автоматически кэширует предыдущие вычисления. Видеть: http://neilmitchell.blogspot.com.au/2008/03/lazy-evaluation-strict-vs-speculative.html?m=1

Подробнее о Haskell Wiki: "Оптимизация компиляторов, таких как GHC, пытается снизить стоимость лени, используя анализ строгости, который пытается определить, какие аргументы функции всегда оцениваются функцией, и, следовательно, может быть оценен вместо этого вызывающим".

"Анализ строгости может выявить факт, что аргумент n является строгим и может быть представлен unboxed. Результирующая функция не будет использовать кучу во время ее работы, как и следовало ожидать".

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

http://www.haskell.org/haskellwiki/Performance/Strictness http://www.haskell.org/haskellwiki/GHC_optimisations