Потоки Java 8: почему параллельный поток медленнее?

Я играю с потоками Java 8 и не могу понять результаты производительности, которые я получаю. У меня 2-х ядерный процессор (Intel i73520M), Windows 8 x64 и 64-битная версия Java 8, обновление 5. Я делаю простую карту для потока / параллельного потока строк и обнаружил, что параллельная версия несколько медленнее.

Function<Stream<String>, Long> timeOperation = (Stream<String> stream) -> {
  long time1 = System.nanoTime();
  final List<String> list = 
     stream
       .map(String::toLowerCase)
       .collect(Collectors.toList());
  long time2 = System.nanoTime();
  return time2 - time1;
};

Consumer<Stream<String>> printTime = stream ->
  System.out.println(timeOperation.apply(stream) / 1000000f);

String[] array = new String[1000000];
Arrays.fill(array, "AbabagalamagA");

printTime.accept(Arrays.stream(array));            // prints around 600
printTime.accept(Arrays.stream(array).parallel()); // prints around 900

Разве параллельная версия не должна быть быстрее, учитывая тот факт, что у меня 2 ядра процессора? Может ли кто-нибудь дать мне подсказку, почему параллельная версия медленнее?

3 ответа

Решение

Здесь параллельно происходит несколько вопросов.

Во-первых, параллельное решение проблемы всегда подразумевает выполнение более актуальной работы, чем выполнение ее последовательно. Накладные расходы связаны с разделением работы между несколькими потоками и объединением или объединением результатов. Проблемы, такие как преобразование коротких строк в нижний регистр, достаточно малы, так что они могут быть забиты параллельным разделением заголовка.

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

Для небольших синтетических тестов рабочая нагрузка часто вычисляет выброшенные результаты. JIT-компиляторы довольно хорошо обнаруживают это и удаляют код, который не дает результатов, которые используются где-либо. Это, вероятно, не происходит в этом случае, но если вы возитесь с другими синтетическими рабочими нагрузками, это, безусловно, может произойти. Конечно, если JIT устраняет нагрузку на эталонный тест, он делает эталонный тест бесполезным.

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

package com.stackru.questions;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.concurrent.TimeUnit;

import org.openjdk.jmh.annotations.*;

public class SO23170832 {
    @State(Scope.Benchmark)
    public static class BenchmarkState {
        static String[] array;
        static {
            array = new String[1000000];
            Arrays.fill(array, "AbabagalamagA");
        }
    }

    @GenerateMicroBenchmark
    @OutputTimeUnit(TimeUnit.SECONDS)
    public List<String> sequential(BenchmarkState state) {
        return
            Arrays.stream(state.array)
                  .map(x -> x.toLowerCase())
                  .collect(Collectors.toList());
    }

    @GenerateMicroBenchmark
    @OutputTimeUnit(TimeUnit.SECONDS)
    public List<String> parallel(BenchmarkState state) {
        return
            Arrays.stream(state.array)
                  .parallel()
                  .map(x -> x.toLowerCase())
                  .collect(Collectors.toList());
    }
}

Я запустил это с помощью команды:

java -jar dist/microbenchmarks.jar ".*SO23170832.*" -wi 5 -i 5 -f 1

(Опции указывают пять итераций разминки, пять итераций бенчмарка и одну разветвленную JVM.) Во время своего запуска JMH выдает много подробных сообщений, которые я пропустил. Итоговые результаты следующие.

Benchmark                       Mode   Samples         Mean   Mean error    Units
c.s.q.SO23170832.parallel      thrpt         5        4.600        5.995    ops/s
c.s.q.SO23170832.sequential    thrpt         5        1.500        1.727    ops/s

Обратите внимание, что результаты отображаются в секундах в секунду, поэтому похоже, что параллельный прогон был примерно в три раза быстрее, чем последовательный прогон. Но у моей машины только два ядра. Хммм. А средняя ошибка за цикл на самом деле больше, чем средняя продолжительность! WAT? Здесь происходит что-то подозрительное.

Это подводит нас к третьему вопросу. При более внимательном рассмотрении рабочей нагрузки мы видим, что он выделяет новый объект String для каждого ввода, а также собирает результаты в список, который включает много перераспределения и копирования. Я предполагаю, что это приведет к изрядному количеству мусора. Мы можем увидеть это, перезапустив тест с включенными сообщениями GC:

java -verbose:gc -jar dist/microbenchmarks.jar ".*SO23170832.*" -wi 5 -i 5 -f 1

Это дает результаты, такие как:

[GC (Allocation Failure)  512K->432K(130560K), 0.0024130 secs]
[GC (Allocation Failure)  944K->520K(131072K), 0.0015740 secs]
[GC (Allocation Failure)  1544K->777K(131072K), 0.0032490 secs]
[GC (Allocation Failure)  1801K->1027K(132096K), 0.0023940 secs]
# Run progress: 0.00% complete, ETA 00:00:20
# VM invoker: /Users/src/jdk/jdk8-b132.jdk/Contents/Home/jre/bin/java
# VM options: -verbose:gc
# Fork: 1 of 1
[GC (Allocation Failure)  512K->424K(130560K), 0.0015460 secs]
[GC (Allocation Failure)  933K->552K(131072K), 0.0014050 secs]
[GC (Allocation Failure)  1576K->850K(131072K), 0.0023050 secs]
[GC (Allocation Failure)  3075K->1561K(132096K), 0.0045140 secs]
[GC (Allocation Failure)  1874K->1059K(132096K), 0.0062330 secs]
# Warmup: 5 iterations, 1 s each
# Measurement: 5 iterations, 1 s each
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: com.stackru.questions.SO23170832.parallel
# Warmup Iteration   1: [GC (Allocation Failure)  7014K->5445K(132096K), 0.0184680 secs]
[GC (Allocation Failure)  7493K->6346K(135168K), 0.0068380 secs]
[GC (Allocation Failure)  10442K->8663K(135168K), 0.0155600 secs]
[GC (Allocation Failure)  12759K->11051K(139776K), 0.0148190 secs]
[GC (Allocation Failure)  18219K->15067K(140800K), 0.0241780 secs]
[GC (Allocation Failure)  22167K->19214K(145920K), 0.0208510 secs]
[GC (Allocation Failure)  29454K->25065K(147456K), 0.0333080 secs]
[GC (Allocation Failure)  35305K->30729K(153600K), 0.0376610 secs]
[GC (Allocation Failure)  46089K->39406K(154624K), 0.0406060 secs]
[GC (Allocation Failure)  54766K->48299K(164352K), 0.0550140 secs]
[GC (Allocation Failure)  71851K->62725K(165376K), 0.0612780 secs]
[GC (Allocation Failure)  86277K->74864K(184320K), 0.0649210 secs]
[GC (Allocation Failure)  111216K->94203K(185856K), 0.0875710 secs]
[GC (Allocation Failure)  130555K->114932K(199680K), 0.1030540 secs]
[GC (Allocation Failure)  162548K->141952K(203264K), 0.1315720 secs]
[Full GC (Ergonomics)  141952K->59696K(159232K), 0.5150890 secs]
[GC (Allocation Failure)  105613K->85547K(184832K), 0.0738530 secs]
1.183 ops/s

Примечание: строки начинаются с # нормальные выходные строки JMH. Все остальные сообщения GC. Это только первая из пяти итераций прогрева, которая предшествует пяти итерациям бенчмарка. Сообщения GC продолжали в том же духе в течение остальных итераций. Я думаю, можно с уверенностью сказать, что в измеренной производительности преобладают издержки GC и что в представленные результаты не следует верить.

На данный момент неясно, что делать. Это чисто синтетическая нагрузка. Очевидно, что это требует очень мало процессорного времени для выполнения реальной работы по сравнению с выделением и копированием. Трудно сказать, что вы на самом деле пытаетесь измерить здесь. Один из подходов состоит в том, чтобы придумать другую рабочую нагрузку, которая в некотором смысле более "реальна". Другой подход заключается в изменении параметров кучи и GC, чтобы избежать GC во время выполнения теста.

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

Warmup...
Benchmark...
Run 0:  sequential 0.12s  -  parallel 0.11s
Run 1:  sequential 0.13s  -  parallel 0.08s
Run 2:  sequential 0.15s  -  parallel 0.08s
Run 3:  sequential 0.12s  -  parallel 0.11s
Run 4:  sequential 0.13s  -  parallel 0.08s

Следующий фрагмент кода содержит полный исходный код, который я использовал для этого теста.

public static void main(String... args) {
    String[] array = new String[1000000];
    Arrays.fill(array, "AbabagalamagA");
    System.out.println("Warmup...");
    for (int i = 0; i < 100; ++i) {
        sequential(array);
        parallel(array);
    }
    System.out.println("Benchmark...");
    for (int i = 0; i < 5; ++i) {
        System.out.printf("Run %d:  sequential %s  -  parallel %s\n",
            i,
            test(() -> sequential(array)),
            test(() -> parallel(array)));
    }
}
private static void sequential(String[] array) {
    Arrays.stream(array).map(String::toLowerCase).collect(Collectors.toList());
}
private static void parallel(String[] array) {
    Arrays.stream(array).parallel().map(String::toLowerCase).collect(Collectors.toList());
}
private static String test(Runnable runnable) {
    long start = System.currentTimeMillis();
    runnable.run();
    long elapsed = System.currentTimeMillis() - start;
    return String.format("%4.2fs", elapsed / 1000.0);
}

Использование нескольких потоков для обработки ваших данных требует некоторых начальных настроек, например, инициализация пула потоков. Эти затраты могут перевесить выгоды от использования этих потоков, особенно если время выполнения уже достаточно низкое. Кроме того, если есть конфликт, например, запущены другие потоки, фоновые процессы и т. Д., Производительность параллельной обработки может еще больше снизиться.

Эта проблема не нова для параллельной обработки. Эта статья дает некоторые подробности в свете Java 8 parallel() и еще несколько вещей, чтобы рассмотреть: http://java.dzone.com/articles/think-twice-using-java-8

Реализация потока в Java по умолчанию является последовательной, если она явно не упомянута параллельно. Когда поток выполняется параллельно, среда выполнения Java разделяет поток на несколько подпотоков. Агрегатные операции повторяются и обрабатывают эти подпотоки параллельно, а затем объединяют результаты. Таким образом, параллельные потоки можно использовать, если у разработчиков есть проблемы с производительностью с последовательными потоками.Пожалуйста, проверьте сравнение производительности:https://github.com/prathamket/Java-8/blob/master/Performance_Implications.java Вы получите общее представление о производительности.

Другие вопросы по тегам