Подтвердить что ты не робот

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

4b9b3361

Ответ 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

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

Ответ 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() и еще несколько вещей, которые следует учитывать: http://java.dzone.com/articles/think-twice-using-java-8

Ответ 4

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