Java 8: производительность потоков и коллекций

146

Я новичок в Java 8. Я до сих пор не знаю API глубоко, но я сделал небольшой неформальный тест, чтобы сравнить производительность нового Streams API и старых добрых коллекций.

Тест состоит в фильтрации списка Integer, и для каждого четного числа, вычислить квадратный корень и хранить его в результате Listиз Double.

Вот код:

    public static void main(String[] args) {
        //Calculating square root of even numbers from 1 to N       
        int min = 1;
        int max = 1000000;

        List<Integer> sourceList = new ArrayList<>();
        for (int i = min; i < max; i++) {
            sourceList.add(i);
        }

        List<Double> result = new LinkedList<>();


        //Collections approach
        long t0 = System.nanoTime();
        long elapsed = 0;
        for (Integer i : sourceList) {
            if(i % 2 == 0){
                result.add(Math.sqrt(i));
            }
        }
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Collections: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


        //Stream approach
        Stream<Integer> stream = sourceList.stream();       
        t0 = System.nanoTime();
        result = stream.filter(i -> i%2 == 0).map(i -> Math.sqrt(i)).collect(Collectors.toList());
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Streams: Elapsed time:\t\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


        //Parallel stream approach
        stream = sourceList.stream().parallel();        
        t0 = System.nanoTime();
        result = stream.filter(i -> i%2 == 0).map(i -> Math.sqrt(i)).collect(Collectors.toList());
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Parallel streams: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));      
    }.

А вот результаты для двухъядерной машины:

    Collections: Elapsed time:        94338247 ns   (0,094338 seconds)
    Streams: Elapsed time:           201112924 ns   (0,201113 seconds)
    Parallel streams: Elapsed time:  357243629 ns   (0,357244 seconds)

Для этого конкретного теста потоки примерно вдвое медленнее, чем коллекции, и параллелизм не помогает (или я использую его неправильно?).

Вопросы:

  • Это честный тест? Я сделал какую-нибудь ошибку?
  • Стримы медленнее коллекций? Кто-нибудь сделал хороший формальный тест по этому поводу?
  • К какому подходу я должен стремиться?

Обновленные результаты.

Я провел тест 1k раз после разогрева JVM (1k итераций), как посоветовал @pveentjer:

    Collections: Average time:      206884437,000000 ns     (0,206884 seconds)
    Streams: Average time:           98366725,000000 ns     (0,098367 seconds)
    Parallel streams: Average time: 167703705,000000 ns     (0,167704 seconds)

В этом случае потоки более производительны. Интересно, что будет наблюдаться в приложении, в котором функция фильтрации вызывается только один или два раза во время выполнения.

Мистер Смит
источник
1
вы пробовали IntStreamвместо этого?
Марк Роттевил,
2
Не могли бы вы правильно измерить? Если все, что вы делаете, - это один прогон, то ваши тесты, конечно же, будут отключены.
skiwi
2
@MisterSmith Можем ли мы получить некоторую информацию о том, как вы разогрели свою JVM, также с помощью тестов 1K?
skiwi
1
А для тех, кто заинтересован в написании правильных микробенчмарков, вот вопрос: stackoverflow.com/questions/504103/…
Мистер Смит,
2
Использование @assylias toListдолжно выполняться параллельно, даже если оно собирается в список, не защищенный потоками, поскольку различные потоки перед объединением будут собираться в ограниченные потоками промежуточные списки.
Стюарт Маркс,

Ответы:

201
  1. Прекратите использовать LinkedListдля чего-либо, кроме тяжелого удаления из середины списка с помощью итератора.

  2. Прекратите писать код тестирования вручную, используйте JMH .

Правильные тесты:

@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@OperationsPerInvocation(StreamVsVanilla.N)
public class StreamVsVanilla {
    public static final int N = 10000;

    static List<Integer> sourceList = new ArrayList<>();
    static {
        for (int i = 0; i < N; i++) {
            sourceList.add(i);
        }
    }

    @Benchmark
    public List<Double> vanilla() {
        List<Double> result = new ArrayList<>(sourceList.size() / 2 + 1);
        for (Integer i : sourceList) {
            if (i % 2 == 0){
                result.add(Math.sqrt(i));
            }
        }
        return result;
    }

    @Benchmark
    public List<Double> stream() {
        return sourceList.stream()
                .filter(i -> i % 2 == 0)
                .map(Math::sqrt)
                .collect(Collectors.toCollection(
                    () -> new ArrayList<>(sourceList.size() / 2 + 1)));
    }
}

Результат:

Benchmark                   Mode   Samples         Mean   Mean error    Units
StreamVsVanilla.stream      avgt        10       17.588        0.230    ns/op
StreamVsVanilla.vanilla     avgt        10       10.796        0.063    ns/op

Как я и ожидал, реализация потока происходит довольно медленно. JIT может встраивать все лямбда-выражения, но не дает такого идеально лаконичного кода, как ванильная версия.

Как правило, потоки Java 8 не волшебны. Они не могли ускорив уже хорошо реализованные вещи (с, вероятно, простые итерации или Java 5 для каждого заявления, заменены Iterable.forEach()и Collection.removeIf()звонков). Потоки больше связаны с удобством и безопасностью кодирования. Удобство - здесь работает компромисс скорости.

Левентов
источник
2
Спасибо, что нашли время, чтобы разложить это. Я не думаю, что изменение LinkedList на ArrayList что-то изменит, поскольку оба теста должны добавлять к нему, время не должно изменяться. В любом случае, не могли бы вы объяснить результаты? Трудно сказать, что вы здесь измеряете (единицы говорят нс / операция, но что считается операцией?).
Мистер Смит
54
Ваш вывод о производительности, хотя и действителен, но преувеличен. Существует множество случаев, когда код потока выполняется быстрее, чем итеративный код, в основном потому, что стоимость доступа к каждому элементу для потоков ниже, чем для простых итераторов. И во многих случаях версия с потоками встраивается во что-то, что эквивалентно рукописной версии. Конечно, дьявол кроется в деталях; любой заданный фрагмент кода может вести себя по-другому.
Брайан Гетц
27
@BrianGoetz, не могли бы вы указать варианты использования, когда потоки быстрее?
Александр
1
В последней версии FMH: используйте @Benchmarkвместо@GenerateMicroBenchmark
pdem
3
@BrianGoetz, не могли бы вы указать варианты использования, когда стримы быстрее?
kiltek
18

1) Вы видите время менее 1 секунды, используя контрольный показатель. Это означает, что на ваши результаты могут сильно повлиять побочные эффекты. Итак, я увеличил вашу задачу в 10 раз

    int max = 10_000_000;

и провел тест. Мои результаты:

Collections: Elapsed time:   8592999350 ns  (8.592999 seconds)
Streams: Elapsed time:       2068208058 ns  (2.068208 seconds)
Parallel streams: Elapsed time:  7186967071 ns  (7.186967 seconds)

без edit ( int max = 1_000_000) результаты были

Collections: Elapsed time:   113373057 ns   (0.113373 seconds)
Streams: Elapsed time:       135570440 ns   (0.135570 seconds)
Parallel streams: Elapsed time:  104091980 ns   (0.104092 seconds)

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

2) После увеличения поток задач стал быстрее (это нормально), но параллельный поток остался слишком медленным. Что не так? Примечание: у вас есть collect(Collectors.toList())команда. Сбор в одну коллекцию по существу создает узкое место в производительности и накладные расходы в случае одновременного выполнения. Относительную стоимость накладных расходов можно оценить, заменив

collecting to collection -> counting the element count

Для потоков это можно сделать с помощью collect(Collectors.counting()). Получил результаты:

Collections: Elapsed time:   41856183 ns    (0.041856 seconds)
Streams: Elapsed time:       546590322 ns   (0.546590 seconds)
Parallel streams: Elapsed time:  1540051478 ns  (1.540051 seconds)

Это большая задача! ( int max = 10000000) Вывод: сбор предметов в коллекцию занял большую часть времени. Самая медленная часть - добавление в список. Кстати, простой ArrayListиспользуется для Collectors.toList().

Сергей Федоров
источник
Вам необходимо провести микробенчмаркинг этого теста, то есть сначала его нужно много раз прогревать, а затем выполнять много раз и усреднять.
skiwi
@skiwi конечно, ты прав, тем более что есть большие отклонения в измерениях. Я провел только базовое расследование и не претендую на точность результатов.
Сергей Федоров
JIT в серверном режиме запускается после 10k выполнений. Затем требуется некоторое время, чтобы скомпилировать код и заменить его.
pveentjer
Об этом предложении: «у вас есть collect(Collectors.toList())команда, т.е. может возникнуть ситуация, когда вам нужно адресовать одну коллекцию несколькими потоками». Я почти уверен, что она toListсобирает в несколько разных экземпляров списка параллельно. Только на последнем шаге в коллекции элементы переносятся в один список, а затем возвращаются. Так что накладных расходов на синхронизацию быть не должно. Вот почему у коллекционеров есть функции поставщика, накопителя и объединителя. (Конечно, это может быть медленным по другим причинам.)
Lii
@Lii Я так же думаю о collectреализации здесь. Но в конечном итоге несколько списков нужно объединить в один, и похоже, что объединение - самая тяжелая операция в данном примере.
Сергей Федоров
4
    public static void main(String[] args) {
    //Calculating square root of even numbers from 1 to N       
    int min = 1;
    int max = 10000000;

    List<Integer> sourceList = new ArrayList<>();
    for (int i = min; i < max; i++) {
        sourceList.add(i);
    }

    List<Double> result = new LinkedList<>();


    //Collections approach
    long t0 = System.nanoTime();
    long elapsed = 0;
    for (Integer i : sourceList) {
        if(i % 2 == 0){
            result.add( doSomeCalculate(i));
        }
    }
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Collections: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


    //Stream approach
    Stream<Integer> stream = sourceList.stream();       
    t0 = System.nanoTime();
    result = stream.filter(i -> i%2 == 0).map(i -> doSomeCalculate(i))
            .collect(Collectors.toList());
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Streams: Elapsed time:\t\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


    //Parallel stream approach
    stream = sourceList.stream().parallel();        
    t0 = System.nanoTime();
    result = stream.filter(i -> i%2 == 0).map(i ->  doSomeCalculate(i))
            .collect(Collectors.toList());
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Parallel streams: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));      
}

static double doSomeCalculate(int input) {
    for(int i=0; i<100000; i++){
        Math.sqrt(i+input);
    }
    return Math.sqrt(input);
}

Я немного изменил код, запустил на своем Mac Book Pro с 8 ядрами, получил разумный результат:

Collections: Elapsed time:      1522036826 ns   (1.522037 seconds)
Streams: Elapsed time:          4315833719 ns   (4.315834 seconds)
Parallel streams: Elapsed time:  261152901 ns   (0.261153 seconds)
Меллон
источник
Я думаю, ваш тест справедлив, вам просто нужно, чтобы на машине было больше ядер процессора.
Mellon
3

Для того, что вы пытаетесь сделать, я бы все равно не использовал обычные java api. Идет масса операций по упаковке / распаковке, поэтому накладные расходы на производительность огромны.

Лично я считаю, что многие разработанные API - это дерьмо, потому что они создают много мусора для объектов.

Попробуйте использовать примитивные массивы double / int и попробуйте сделать это однопоточным и посмотрите, какова производительность.

PS: Возможно, вы захотите взглянуть на JMH, чтобы выполнить тест. Он устраняет некоторые типичные ошибки, такие как разогрев JVM.

Pveentjer
источник
LinkedLists даже хуже, чем ArrayLists, потому что вам нужно создать все объекты узлов. Оператор мода также медлителен как собака. Я считаю, что что-то вроде 10/15 циклов + это истощает конвейер команд. Если вы хотите сделать очень быстрое деление на 2, просто сдвиньте бит числа 1 вправо. Это базовые приемы, но я уверен, что есть дополнительные приемы, позволяющие ускорить процесс, но они, вероятно, более специфичны для проблемы.
pveentjer
Я в курсе бокса. Это просто неофициальный тест. Идея состоит в том, чтобы иметь одинаковое количество упаковок / распаковок как в тестах коллекций, так и в тестах потоков.
Мистер Смит
Сначала я хотел бы убедиться, что это не ошибка измерения. Попробуйте выполнить тест несколько раз, прежде чем выполнять настоящий тест. Тогда, по крайней мере, у вас есть разогрев JVM, и код правильно JITTED. Без этого вы, вероятно, сделаете неправильные выводы.
pveentjer
Хорошо, по твоему совету опубликую новые результаты. Я посмотрел на JMH, но для этого требуется Maven, и для настройки требуется некоторое время. Спасибо, в любом случае.
Mister Smith
Я считаю, что лучше не думать о тестах производительности в терминах «того, что вы пытаетесь сделать». т.е. обычно такие упражнения достаточно упрощены, чтобы их можно было продемонстрировать, но достаточно сложны, чтобы выглядеть так, как будто их можно / нужно упростить.
ryvantage