Java 8: kinerja Streams vs Collections

140

Saya baru mengenal Java 8. Saya masih belum tahu tentang API secara mendalam, tetapi saya telah membuat tolok ukur informal kecil untuk membandingkan kinerja API Streams baru dengan Koleksi lama yang bagus.

Tes terdiri dalam menyaring daftar Integer, dan untuk setiap nomor bahkan, menghitung akar kuadrat dan menyimpannya dalam hasil Listdari Double.

Ini kodenya:

    public static void main(String[] args) {
        //Calculating square root of even numbers from 1 to N       
        int min = 1;
        int max = 1000000;

        List<Integer> sourceList = new ArrayList<>();
        for (int i = min; i < max; i++) {
            sourceList.add(i);
        }

        List<Double> result = new LinkedList<>();


        //Collections approach
        long t0 = System.nanoTime();
        long elapsed = 0;
        for (Integer i : sourceList) {
            if(i % 2 == 0){
                result.add(Math.sqrt(i));
            }
        }
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Collections: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


        //Stream approach
        Stream<Integer> stream = sourceList.stream();       
        t0 = System.nanoTime();
        result = stream.filter(i -> i%2 == 0).map(i -> Math.sqrt(i)).collect(Collectors.toList());
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Streams: Elapsed time:\t\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


        //Parallel stream approach
        stream = sourceList.stream().parallel();        
        t0 = System.nanoTime();
        result = stream.filter(i -> i%2 == 0).map(i -> Math.sqrt(i)).collect(Collectors.toList());
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Parallel streams: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));      
    }.

Dan berikut adalah hasil untuk mesin dual core:

    Collections: Elapsed time:        94338247 ns   (0,094338 seconds)
    Streams: Elapsed time:           201112924 ns   (0,201113 seconds)
    Parallel streams: Elapsed time:  357243629 ns   (0,357244 seconds)

Untuk pengujian khusus ini, streaming sekitar dua kali lebih lambat dari koleksi, dan paralelisme tidak membantu (atau apakah saya menggunakannya dengan cara yang salah?).

Pertanyaan:

  • Apakah tes ini adil? Apakah saya melakukan kesalahan?
  • Apakah streaming lebih lambat dari koleksi? Adakah yang membuat tolok ukur formal yang bagus untuk ini?
  • Pendekatan mana yang harus saya perjuangkan?

Hasil yang diperbarui.

Saya menjalankan tes 1k kali setelah pemanasan JVM (iterasi 1k) seperti yang disarankan oleh @pveentjer:

    Collections: Average time:      206884437,000000 ns     (0,206884 seconds)
    Streams: Average time:           98366725,000000 ns     (0,098367 seconds)
    Parallel streams: Average time: 167703705,000000 ns     (0,167704 seconds)

Dalam hal ini stream lebih performan. Saya ingin tahu apa yang akan diamati dalam aplikasi di mana fungsi penyaringan hanya dipanggil sekali atau dua kali selama runtime.

Tuan Smith
sumber
1
Sudahkah Anda mencobanya dengan IntStreambukan?
Mark Rotteveel
2
Bisakah Anda mengukur dengan benar? Jika semua yang Anda lakukan adalah sekali jalan, maka tolok ukur Anda tentu saja tidak aktif.
skiwi
2
@ PakSmith Bisakah kami memiliki transparansi tentang bagaimana Anda menghangatkan JVM Anda, juga dengan tes 1K?
skiwi
1
Dan bagi mereka yang tertarik dalam membuat microbenchmarks yang benar, inilah pertanyaannya: stackoverflow.com/questions/504103/…
Mister Smith
2
@assylias Using toListharus dijalankan secara paralel bahkan jika itu kumpulkan ke daftar non-thread-safe, karena utas yang berbeda akan mengumpulkan ke daftar perantara yang dibatasi utas sebelum digabungkan.
Stuart Marks

Jawaban:

192
  1. Hentikan penggunaan LinkedListuntuk apa pun kecuali menghapus yang berat dari tengah daftar menggunakan iterator.

  2. Berhenti menulis kode benchmark dengan tangan, gunakan JMH .

Tolok ukur yang tepat:

@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@OperationsPerInvocation(StreamVsVanilla.N)
public class StreamVsVanilla {
    public static final int N = 10000;

    static List<Integer> sourceList = new ArrayList<>();
    static {
        for (int i = 0; i < N; i++) {
            sourceList.add(i);
        }
    }

    @Benchmark
    public List<Double> vanilla() {
        List<Double> result = new ArrayList<>(sourceList.size() / 2 + 1);
        for (Integer i : sourceList) {
            if (i % 2 == 0){
                result.add(Math.sqrt(i));
            }
        }
        return result;
    }

    @Benchmark
    public List<Double> stream() {
        return sourceList.stream()
                .filter(i -> i % 2 == 0)
                .map(Math::sqrt)
                .collect(Collectors.toCollection(
                    () -> new ArrayList<>(sourceList.size() / 2 + 1)));
    }
}

Hasil:

Benchmark                   Mode   Samples         Mean   Mean error    Units
StreamVsVanilla.stream      avgt        10       17.588        0.230    ns/op
StreamVsVanilla.vanilla     avgt        10       10.796        0.063    ns/op

Sama seperti yang saya harapkan implementasi aliran cukup lambat. JIT mampu meng-inline semua hal lambda tetapi tidak menghasilkan kode sesingkat versi vanilla.

Secara umum, aliran Java 8 tidak ajaib. Mereka tidak dapat mempercepat hal-hal yang sudah diimplementasikan dengan baik (dengan, mungkin, iterasi sederhana atau Java 5 untuk masing-masing pernyataan diganti Iterable.forEach()dan Collection.removeIf()panggilan). Streaming lebih banyak tentang pengkodean kenyamanan dan keamanan. Kenyamanan - tradeoff cepat bekerja di sini.

leventov
sumber
2
Terima kasih telah meluangkan waktu untuk menyusun ini. Saya tidak berpikir mengubah LinkedList untuk ArrayList akan mengubah apa pun, karena kedua tes harus menambahkannya, waktunya tidak akan terpengaruh. Ngomong-ngomong, bisakah Anda menjelaskan hasilnya? Sulit untuk mengatakan apa yang Anda ukur di sini (unit mengatakan ns / op, tetapi apa yang dianggap sebagai op?).
Tuan Smith
52
Kesimpulan Anda tentang kinerja, meskipun valid, terlalu berlebihan. Ada banyak kasus di mana kode aliran lebih cepat daripada kode iteratif, terutama karena biaya akses per elemen lebih murah dengan stream daripada dengan iterator biasa. Dan dalam banyak kasus, versi stream sejalan dengan sesuatu yang setara dengan versi tulisan tangan. Tentu saja, iblis ada dalam rinciannya; setiap bit kode yang diberikan mungkin berperilaku berbeda.
Brian Goetz
26
@BrianGoetz, bisakah Anda menentukan kasus penggunaan, ketika streaming lebih cepat?
Alexandr
1
Dalam versi terakhir FMH: gunakan @Benchmarksebagai ganti@GenerateMicroBenchmark
pdem
3
@BrianGoetz, Bisakah Anda menentukan kasus penggunaan, ketika Streaming lebih cepat?
kiltek
17

1) Anda melihat waktu kurang dari 1 detik menggunakan tolok ukur Anda. Itu berarti ada pengaruh kuat efek samping pada hasil Anda. Jadi, saya meningkatkan tugas Anda 10 kali

    int max = 10_000_000;

dan jalankan patokan Anda. Hasil saya:

Collections: Elapsed time:   8592999350 ns  (8.592999 seconds)
Streams: Elapsed time:       2068208058 ns  (2.068208 seconds)
Parallel streams: Elapsed time:  7186967071 ns  (7.186967 seconds)

tanpa edit ( int max = 1_000_000) hasilnya tadinya

Collections: Elapsed time:   113373057 ns   (0.113373 seconds)
Streams: Elapsed time:       135570440 ns   (0.135570 seconds)
Parallel streams: Elapsed time:  104091980 ns   (0.104092 seconds)

Ini seperti hasil Anda: streaming lebih lambat daripada koleksi. Kesimpulan: banyak waktu dihabiskan untuk inisialisasi aliran / transmisi nilai.

2) Setelah meningkatkan aliran tugas menjadi lebih cepat (tidak apa-apa), tetapi aliran paralel tetap terlalu lambat. Apa yang salah? Catatan: Anda ada collect(Collectors.toList())dalam perintah Anda. Mengumpulkan ke satu koleksi pada dasarnya memperkenalkan bottleneck dan overhead kinerja jika terjadi eksekusi bersamaan. Dimungkinkan untuk memperkirakan biaya relatif overhead dengan mengganti

collecting to collection -> counting the element count

Untuk streaming, hal itu dapat dilakukan oleh collect(Collectors.counting()). Saya mendapat hasil:

Collections: Elapsed time:   41856183 ns    (0.041856 seconds)
Streams: Elapsed time:       546590322 ns   (0.546590 seconds)
Parallel streams: Elapsed time:  1540051478 ns  (1.540051 seconds)

Itu untuk tugas besar! ( int max = 10000000) Kesimpulan: mengumpulkan barang ke koleksi membutuhkan waktu yang lama. Bagian paling lambat ditambahkan ke daftar. BTW, sederhana ArrayListdigunakan untuk Collectors.toList().

Sergey Fedorov
sumber
Anda perlu menandai microbenchmark ini, artinya harus dipanaskan terlebih dahulu berkali-kali, dan kemudian dieksekusi banyak tmes dan rata-rata.
skiwi
@skiwi yakin, Anda benar, terutama karena ada penyimpangan besar dalam pengukuran. Saya hanya melakukan investigasi dasar dan tidak berpura-pura hasilnya tepat.
Sergey Fedorov
JIT dalam mode server, menendang setelah eksekusi 10k. Dan kemudian butuh beberapa waktu untuk mengkompilasi kode dan menukarnya.
pveentjer
Tentang kalimat ini: " Anda ada collect(Collectors.toList())dalam perintah Anda, yaitu mungkin ada situasi ketika Anda perlu mengatasi Koleksi tunggal dengan banyak utas. " Saya hampir yakin bahwa toListmengumpulkan ke beberapa contoh daftar yang berbeda secara paralel. Hanya sebagai langkah terakhir dalam koleksi, elemen-elemen ditransfer ke satu daftar dan kemudian dikembalikan. Jadi seharusnya tidak ada sinkronisasi biaya overhead. Inilah sebabnya mengapa kolektor memiliki fungsi pemasok, akumulator, dan kombinator. (Bisa lambat karena alasan lain, tentu saja.)
Lii
@ Lii Saya pikir cara yang sama tentang collectimplementasi di sini. Tetapi pada akhirnya beberapa daftar harus digabung menjadi satu, dan sepertinya penggabungan adalah operasi yang paling berat dalam contoh yang diberikan.
Sergey Fedorov
4
    public static void main(String[] args) {
    //Calculating square root of even numbers from 1 to N       
    int min = 1;
    int max = 10000000;

    List<Integer> sourceList = new ArrayList<>();
    for (int i = min; i < max; i++) {
        sourceList.add(i);
    }

    List<Double> result = new LinkedList<>();


    //Collections approach
    long t0 = System.nanoTime();
    long elapsed = 0;
    for (Integer i : sourceList) {
        if(i % 2 == 0){
            result.add( doSomeCalculate(i));
        }
    }
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Collections: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


    //Stream approach
    Stream<Integer> stream = sourceList.stream();       
    t0 = System.nanoTime();
    result = stream.filter(i -> i%2 == 0).map(i -> doSomeCalculate(i))
            .collect(Collectors.toList());
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Streams: Elapsed time:\t\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


    //Parallel stream approach
    stream = sourceList.stream().parallel();        
    t0 = System.nanoTime();
    result = stream.filter(i -> i%2 == 0).map(i ->  doSomeCalculate(i))
            .collect(Collectors.toList());
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Parallel streams: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));      
}

static double doSomeCalculate(int input) {
    for(int i=0; i<100000; i++){
        Math.sqrt(i+input);
    }
    return Math.sqrt(input);
}

Saya mengubah kode sedikit, berlari di mac book pro saya yang memiliki 8 core, saya mendapat hasil yang masuk akal:

Koleksi: Waktu yang berlalu: 1522036826 ns (1,522037 detik)

Streaming: Waktu yang berlalu: 4315833719 ns (4.315834 detik)

Aliran paralel: Waktu yang berlalu: 261152901 ns (0,261153 detik)

Mellon
sumber
Saya pikir tes Anda adil, Anda hanya perlu mesin memiliki lebih banyak core cpu.
Mellon
3

Untuk apa yang Anda coba lakukan, saya tidak akan menggunakan java api biasa. Ada satu ton tinju / unboxing yang sedang terjadi, jadi ada overhead kinerja yang sangat besar.

Secara pribadi saya pikir banyak API yang dirancang adalah omong kosong karena mereka membuat banyak objek sampah.

Coba gunakan array primitif double / int dan coba lakukan single threaded dan lihat kinerjanya.

PS: Anda mungkin ingin melihat JMH untuk melakukan benchmark. Ini menangani beberapa perangkap khas seperti pemanasan JVM.

pveentjer
sumber
LinkedLists bahkan lebih buruk daripada ArrayLists karena Anda perlu membuat semua objek node. Operator mod juga sangat lambat. Saya percaya sekitar 10/15 siklus + itu menguras pipa instruksi. Jika Anda ingin melakukan pembagian yang sangat cepat dengan 2, geser saja angka 1 bit ke kanan. Ini adalah trik dasar, tapi saya yakin ada trik lanjutan mode untuk mempercepat, tetapi ini mungkin lebih khusus masalah.
pveentjer
Saya menyadari tinju. Ini hanyalah patokan informal. Idenya adalah memiliki jumlah yang sama dari tinju / unboxing di kedua koleksi dan tes aliran.
Tuan Smith
Pertama saya akan memastikan bahwa itu tidak mengukur kesalahan. Coba jalankan patokan beberapa kali sebelum Anda melakukan patokan nyata. Maka setidaknya Anda memiliki pemanasan JVM keluar dari jalan dan kode JITTED dengan benar. Tanpa ini, Anda mungkin membuat kesimpulan yang salah.
pveentjer
Oke, saya akan memposting hasil baru mengikuti saran Anda. Saya sudah melihat JMH tetapi membutuhkan Maven dan butuh beberapa waktu untuk mengkonfigurasi. Bagaimanapun, terima kasih.
Tuan Smith
Saya pikir yang terbaik adalah menghindari memikirkan tes benchmark dalam hal "Untuk apa yang Anda coba lakukan." yaitu, biasanya latihan-latihan semacam ini cukup disederhanakan untuk diperlihatkan, tetapi cukup rumit sehingga latihan-latihan itu tampak / dapat disederhanakan.
ryvantage