Вычисление факториала или мощь Stream API

в 11:28, , рубрики: java, spliterator, stream api, Streams, многопоточность, параллельное программирование, Программирование, Совершенный код

На днях появилась статья 5nw Два способа быстрого вычисления факториала, в которой приводится идея ускорения подсчёта факториала с помощью группировки перемножаемых чисел в дерево по принципу «разделяй и властвуй». Взглянув на это, я сразу понял, что тут параллельные потоки Java проявят себя во всей красе: ведь они делят задачу на подзадачи с помощью сплитераторов именно таким образом. Получается, что быстрая реализация будет ещё и красивой:

public static BigInteger streamedParallel(int n) {
    if(n < 2) return BigInteger.valueOf(1);
    return IntStream.rangeClosed(2, n).parallel().mapToObj(BigInteger::valueOf).reduce(BigInteger::multiply).get();
}


К сожалению, в статье 5nw нет подробностей замера производительности. Сколько тестов проводилось? Был ли разогрев? Оценивалась ли погрешность замеров времени? Не выкосил ли JIT-компилятор часть вычислений, потому что их результаты не использовались? А если использовались (например, полученное число выводилось в файл), то исключён ли факт использования из подсчёта времени? В этом плане хочу сказать спасибо Алексею Шипилёву, который своей библиотекой JMH, а также многочисленными презентациями и статьями привил какую-никакую культуру бенчмаркинга в Java-сообществе.

У меня будет такой код бенчмарка:

import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.IntStream;
import java.math.BigInteger;

@Warmup(iterations=5)
@Measurement(iterations=10)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Fork(2)
public class Factorial {
    @Param({"10", "100", "1000", "10000", "50000"})
    private int n;

    public static BigInteger naive(int n) {
        BigInteger r = BigInteger.valueOf(1);
        for (int i = 2; i <= n; ++i)
            r = r.multiply(BigInteger.valueOf(i));
        return r;
    }

    public static BigInteger streamed(int n) {
        if(n < 2) return BigInteger.valueOf(1);
        return IntStream.rangeClosed(2, n).mapToObj(BigInteger::valueOf).reduce(BigInteger::multiply).get();
    }
    
    public static BigInteger streamedParallel(int n) {
        if(n < 2) return BigInteger.valueOf(1);
        return IntStream.rangeClosed(2, n).parallel().mapToObj(BigInteger::valueOf).reduce(BigInteger::multiply).get();
    }

    @Benchmark    
    public void testNaive(Blackhole bh) {
        bh.consume(naive(n));
    }
    
    @Benchmark    
    public void testStreamed(Blackhole bh) {
        bh.consume(streamed(n));
    }
    
    @Benchmark    
    public void testStreamedParallel(Blackhole bh) {
        bh.consume(streamedParallel(n));
    }
}

Я сравнил три реализации — наивную, на обычном потоке и на параллельном потоке. Разумно проверить алгоритм для различных значений n — 10, 100, 1000, 10000 и 50000, чтобы представить динамику изменения результатов. Проводится пять итераций разогрева и десять итераций с замером, всё это повторяется дважды (с перезапуском Java-машины), то есть для каждого теста делается 20 замеров. Обратите внимание на чёрную дыру (Blackhole): она нужна, чтобы JIT-компилятор не удалил результат вычислений, решив, что он всё равно не используется.

Протестировал я на ноутбуке с процессором Core i7-4702MQ (8 хардварных тредов). Получаются вот такие результаты:

Benchmark                         (n)  Mode  Cnt       Score       Error  Units
Factorial.testNaive                10  avgt   20       0.298 ±     0.005  us/op
Factorial.testNaive               100  avgt   20       5.113 ±     0.195  us/op
Factorial.testNaive              1000  avgt   20     278.601 ±     9.586  us/op
Factorial.testNaive             10000  avgt   20   32232.618 ±   889.512  us/op
Factorial.testNaive             50000  avgt   20  972369.158 ± 29287.950  us/op
Factorial.testStreamed             10  avgt   20       0.339 ±     0.021  us/op
Factorial.testStreamed            100  avgt   20       5.432 ±     0.246  us/op
Factorial.testStreamed           1000  avgt   20     268.366 ±     4.859  us/op
Factorial.testStreamed          10000  avgt   20   30429.526 ±   435.611  us/op
Factorial.testStreamed          50000  avgt   20  896719.207 ±  7995.599  us/op
Factorial.testStreamedParallel     10  avgt   20       6.470 ±     0.515  us/op
Factorial.testStreamedParallel    100  avgt   20      11.280 ±     1.094  us/op
Factorial.testStreamedParallel   1000  avgt   20      74.174 ±     2.647  us/op
Factorial.testStreamedParallel  10000  avgt   20    2826.643 ±    52.831  us/op
Factorial.testStreamedParallel  50000  avgt   20   49196.070 ±   464.634  us/op

Наивный тест в целом подтверждает теоретическое предположение о квадратичой сложности алгоритма. Разделим время на n²:

n = 10:    0.002980
n = 100:   0.000511
n = 1000:  0.000279
n = 10000: 0.000322
n = 50000: 0.000389

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

Нераспараллеленный поток для малых n работает ожидаемо несколько большее время (на 13% для n = 10 и на 6% для n = 100): всё же обвязка Stream API что-то съедает. Однако удивительно, что для больших n потоковый вариант работает на 4-8% быстрее, хотя алгоритмически выглядит так же. Очередное подтверждение тому, что о производительности опасно рассуждать теоретически, не проводя замеры. Оптимизации JIT-компилятора, кэширование процессора, предсказание ветвления и прочие факторы очень трудно учесть в теории.

Распараллеленный поток ожидаемо существенно медленнее для очень коротких операций. Однако прирост скорости наблюдается уже при n = 1000, когда полный расчёт занимает около 270 мкс в последовательном режиме и только 75 в параллельном. Это отлично согласуется со Stream Parallel Guidance (спасибо apangin за ссылку), где сказано, что распараллеливать имеет смысл задачи, которые выполняются дольше 100 мкс. При больших же значениях n распараллеленный поток на коне: мы получаем прирост скорости в 18 раз. В целом это согласуется с приростом у 5nw, помноженным на число потоков (1.7/0.8*8 = 17).

У ForkJoinPool очень маленький оверхед, поэтому даже миллисекундная задача получает выигрыш по скорости. При этом алгоритмы вида «разделяй и властвуй» естественным образом перекладываются на параллельные потоки, благодаря чему параллельный поток может оказаться существенно быстрее последовательного. Stream API многие ругают, но за параллелизмом всё же будущее.

Автор: lany

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js