Вопрос-ответ

Java 8's streams: why parallel stream is slower?

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

Я играю с потоками Java 8 и не могу понять результаты производительности, которые получаю. У меня 2-ядерный процессор (Intel i73520M), Windows 8 x64 и 64-разрядная версия Java 8 update 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 ядра процессора?
Кто-нибудь может подсказать мне, почему параллельная версия медленнее?

Переведено автоматически
Ответ 1

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

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

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

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

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

package com.stackoverflow.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

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

Это подводит нас к третьей проблеме. При более внимательном рассмотрении рабочей нагрузки мы можем видеть, что она выделяет новый объект 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.stackoverflow.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 и что сообщаемым результатам не следует верить.

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

Ответ 2

При выполнении тестов вам следует обращать внимание на 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);
}
Ответ 3

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

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

Ответ 4

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

java performance java-8 java-stream