it-swarm.com.ru

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 ядра процессора? Может ли кто-нибудь дать мне подсказку, почему параллельная версия медленнее?

51
Eugene Loy

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

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

Вторая проблема заключается в том, что программа 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

Обратите внимание, что результаты отображаются в секундах в секунду, поэтому похоже, что параллельный запуск был примерно в три раза быстрее, чем последовательный запуск. Но у моей машины только два ядра. Хммм. И средняя ошибка за цикл на самом деле больше, чем среднее время выполнения! 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.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 и что в представленные результаты не следует верить.

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

131
Stuart Marks

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

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

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

8
joe776