I flussi di Java 8: perché il flusso parallelo è più lento?


Sto giocando con i flussi di Java 8 e non riesco a capire i risultati delle prestazioni che sto ottenendo. Ho CPU 2 core (Intel i73520M), Windows 8 x64 e aggiornamento Java 8 a 64 bit 5. Sto facendo una semplice mappa su stream / flusso parallelo di stringhe e ho scoperto che la versione parallela è un po ' più lenta.

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

La versione parallela non dovrebbe essere più veloce, considerando il fatto che ho 2 core della CPU? Qualcuno potrebbe darmi un suggerimento perché la versione parallela è più lenta?

Author: axiopisty, 2014-04-19

4 answers

Ci sono diversi problemi in corso qui in parallelo, per così dire.

Il primo è che risolvere un problema in parallelo implica sempre eseguire più lavoro effettivo che farlo in sequenza. L'overhead è coinvolto nella divisione del lavoro tra più thread e nell'unione o fusione dei risultati. Problemi come la conversione di stringhe corte in minuscolo sono abbastanza piccoli da rischiare di essere sommersi dal sovraccarico di divisione parallelo.

Il secondo problema è che il benchmarking Il programma Java è molto sottile ed è molto facile ottenere risultati confusi. Due problemi comuni sono la compilazione JIT e l'eliminazione del codice morto. I benchmark brevi spesso terminano prima o durante la compilazione JIT, quindi non misurano il throughput di picco, e in effetti potrebbero misurare il JIT stesso. Quando si verifica la compilazione è in qualche modo non deterministico, quindi può causare risultati di variare selvaggiamente pure.

Per piccoli benchmark sintetici, il carico di lavoro spesso calcola i risultati generati lontano. I compilatori JIT sono abbastanza bravi a rilevarlo ed eliminare il codice che non produce risultati che vengono utilizzati ovunque. Questo probabilmente non sta accadendo in questo caso, ma se si armeggia con altri carichi di lavoro sintetici, può certamente accadere. Naturalmente, se il JIT elimina il carico di lavoro del benchmark, rende il benchmark inutile.

Consiglio vivamente di utilizzare un framework di benchmarking ben sviluppato come JMH invece di rotolare a mano uno dei tuoi. JMH ha servizi per evitare le insidie di benchmarking comuni, compresi questi, ed è abbastanza facile da configurare ed eseguire. Ecco il tuo benchmark convertito per usare 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());
    }
}

L'ho eseguito usando il comando:

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

(Le opzioni indicano cinque iterazioni di riscaldamento, cinque iterazioni di benchmark e una JVM biforcuta.) Durante la sua esecuzione, JMH emette molti messaggi prolissi, che ho eliso. I risultati di sintesi sono i seguenti.

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

Si noti che i risultati sono in ops al secondo, quindi sembra la corsa parallela era circa tre volte più veloce della corsa sequenziale. Ma la mia macchina ha solo due core. Hmmm. E l'errore medio per esecuzione è in realtà più grande del runtime medio! WAT? Sta succedendo qualcosa di sospetto qui.

Questo ci porta ad un terzo numero. Osservando più da vicino il carico di lavoro, possiamo vedere che alloca un nuovo oggetto String per ogni input e raccoglie anche i risultati in un elenco, che comporta molte riallocazioni e copie. Immagino che questo si tradurrà in un buona quantità di raccolta dei rifiuti. Possiamo vedere questo eseguendo nuovamente il benchmark con i messaggi GC abilitati:

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

Questo dà risultati come:

[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

Nota: le righe che iniziano con # sono normali linee di output JMH. Tutto il resto sono messaggi GC. Questa è solo la prima delle cinque iterazioni di riscaldamento, che precede cinque iterazioni di benchmark. I messaggi GC continuarono nello stesso modo durante il resto delle iterazioni. Penso che sia sicuro dire che le prestazioni misurate sono dominato da GC overhead e che i risultati riportati non dovrebbero essere creduti.

A questo punto non è chiaro cosa fare. Questo è puramente un carico di lavoro sintetico. Implica chiaramente pochissimo tempo di CPU per eseguire il lavoro effettivo rispetto all'allocazione e alla copia. È difficile dire cosa stai veramente cercando di misurare qui. Un approccio sarebbe quello di venire con un carico di lavoro diverso che è in un certo senso più "reale."Un altro approccio sarebbe quello di modificare i parametri heap e GC per evitare GC durante la corsa benchmark.

 153
Author: Stuart Marks, 2015-07-15 16:47:33

Quando si eseguono benchmark, si dovrebbe prestare attenzione alla compilazione JIT e che i comportamenti di temporizzazione possono cambiare, in base alla quantità di percorsi di codice compilati JIT. Se aggiungo una fase di riscaldamento al tuo programma di test, la versione parallela è un po ' più veloce della versione sequenziale. Ecco i risultati:

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

Il seguente frammento di codice contiene il codice sorgente completo che ho usato per questo test.

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);
}
 17
Author: nosid, 2018-10-03 19:04:04

L'utilizzo di più thread per elaborare i dati comporta alcuni costi di installazione iniziali, ad esempio l'inizializzazione del pool di thread. Questi costi potrebbero superare il guadagno derivante dall'utilizzo di tali thread, specialmente se il runtime è già piuttosto basso. Inoltre, se c'è contesa, ad esempio altri thread in esecuzione, processi in background, ecc., le prestazioni dell'elaborazione parallela possono diminuire ulteriormente.

Questo problema non è nuovo per l'elaborazione parallela. Questo articolo fornisce alcuni dettagli alla luce di Java 8 parallel() e alcune altre cose da considerare: https://dzone.com/articles/think-twice-using-java-8

 10
Author: joe776, 2021-01-20 10:17:41

L'implementazione del flusso in Java è sequenziale di default a meno che non sia esplicitamente menzionata in parallelo. Quando un flusso viene eseguito in parallelo, il runtime Java suddivide il flusso in più sotto-flussi. Le operazioni aggregate iterano ed elaborano questi sotto-flussi in parallelo e quindi combinano i risultati. Quindi, i flussi paralleli possono essere utilizzati se gli sviluppatori hanno implicazioni sulle prestazioni con i flussi sequenziali. Si prega di verificare il confronto delle prestazioni : https://github.com/prathamket/Java-8/blob/master/Performance_Implications.java Si otterrà l'idea generale circa le prestazioni.

 3
Author: Prathamesh Ketgale, 2019-08-27 17:52:23