Java 8: hiệu suất của Luồng so với Bộ sưu tập


140

Tôi mới biết về Java 8. Tôi vẫn chưa biết sâu về API, nhưng tôi đã tạo một điểm chuẩn không chính thức nhỏ để so sánh hiệu suất của API Streams mới với các Bộ sưu tập cũ tốt.

Bài kiểm tra bao gồm lọc một danh sách Integervà cho mỗi số chẵn, tính toán căn bậc hai và lưu trữ nó trong kết quả Listcủa Double.

Đây là mã:

    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));      
    }.

Và đây là kết quả cho một máy lõi kép:

    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)

Đối với thử nghiệm cụ thể này, các luồng chậm hơn khoảng hai lần so với các bộ sưu tập và song song không giúp được gì (hoặc tôi đang sử dụng sai cách?).

Câu hỏi:

  • Thử nghiệm này có công bằng không? Tôi đã phạm sai lầm nào chưa?
  • Là luồng chậm hơn bộ sưu tập? Có ai đã làm một tiêu chuẩn chính thức tốt về điều này?
  • Cách tiếp cận nào tôi nên phấn đấu?

Cập nhật kết quả.

Tôi đã chạy thử nghiệm 1k lần sau khi khởi động JVM (lặp lại 1k) theo lời khuyên của @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)

Trong trường hợp này, luồng là hiệu suất cao hơn. Tôi tự hỏi những gì sẽ được quan sát trong một ứng dụng trong đó chức năng lọc chỉ được gọi một hoặc hai lần trong thời gian chạy.


1
IntStreamthay vào đó bạn đã thử nó chưa?
Mark Rotteveel

2
Bạn có thể vui lòng đo đúng không? Nếu tất cả những gì bạn đang làm là một lần chạy, thì điểm chuẩn của bạn tất nhiên sẽ bị tắt.
skiwi

2
@MisterSmith Chúng tôi có thể minh bạch về cách bạn làm nóng JVM của mình không, với các bài kiểm tra 1K?
skiwi

1
Và đối với những người quan tâm đến việc viết các microbenchmark chính xác, đây là câu hỏi: stackoverflow.com/questions/504103/ mẹo
Mister Smith

2
@assylias Sử dụng toListnên chạy song song ngay cả khi nó thu thập vào danh sách không an toàn luồng, vì các luồng khác nhau sẽ thu thập vào danh sách trung gian giới hạn luồng trước khi được hợp nhất.
Stuart Marks

Câu trả lời:


192
  1. Ngừng sử dụng LinkedListcho bất cứ điều gì nhưng loại bỏ nặng từ giữa danh sách bằng cách sử dụng iterator.

  2. Dừng viết mã điểm chuẩn bằng tay, sử dụng JMH .

Điểm chuẩn phù hợp:

@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)));
    }
}

Kết quả:

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

Giống như tôi dự kiến ​​triển khai luồng khá chậm. JIT có thể nội tuyến tất cả các công cụ lambda nhưng không tạo ra mã ngắn gọn hoàn hảo như phiên bản vanilla.

Nói chung, luồng Java 8 không phải là phép thuật. Họ không thể tăng tốc những thứ đã được triển khai tốt (với, có thể là các lần lặp đơn giản hoặc các câu lệnh cho mỗi câu lệnh của Java 5 được thay thế bằng Iterable.forEach()Collection.removeIf()các lệnh gọi). Các luồng là nhiều hơn về sự tiện lợi và an toàn mã hóa. Thuận tiện - đánh đổi tốc độ đang làm việc ở đây.


2
Cảm ơn đã dành thời gian để băng ghế này. Tôi không nghĩ việc thay đổi LinkedList cho ArrayList sẽ thay đổi bất cứ điều gì, vì cả hai bài kiểm tra nên thêm vào đó, thời gian sẽ không bị ảnh hưởng. Dù sao, bạn có thể vui lòng giải thích kết quả? Thật khó để nói những gì bạn đang đo ở đây (đơn vị nói ns / op, nhưng những gì được coi là op?).
Smith

52
Kết luận của bạn về hiệu suất, trong khi hợp lệ, là quá mức. Có rất nhiều trường hợp mã luồng nhanh hơn mã lặp, phần lớn là do chi phí truy cập theo từng phần tử rẻ hơn với luồng so với mã lặp đơn giản. Và trong nhiều trường hợp, phiên bản stream sẽ phù hợp với phiên bản viết tay. Tất nhiên, ma quỷ là trong các chi tiết; bất kỳ bit nào của mã có thể hoạt động khác nhau.
Brian Goetz

26
@BrianGoetz, bạn có thể vui lòng chỉ định các trường hợp sử dụng không, khi các luồng nhanh hơn?
Alexandr

1
Trong phiên bản cuối cùng của FMH: sử dụng @Benchmarkthay vì@GenerateMicroBenchmark
pdem

3
@BrianGoetz, Bạn có thể chỉ định các trường hợp sử dụng không, khi Luồng nhanh hơn?
kiltek

17

1) Bạn thấy thời gian ít hơn 1 giây bằng cách sử dụng điểm chuẩn của bạn. Điều đó có nghĩa là có thể có ảnh hưởng mạnh mẽ của tác dụng phụ đến kết quả của bạn. Vì vậy, tôi đã tăng nhiệm vụ của bạn lên 10 lần

    int max = 10_000_000;

và chạy điểm chuẩn của bạn. Kết quả của tôi:

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)

không có int max = 1_000_000kết quả chỉnh sửa ( )

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)

Giống như kết quả của bạn: luồng chậm hơn bộ sưu tập. Kết luận: đã dành nhiều thời gian cho việc khởi tạo / truyền giá trị luồng.

2) Sau khi tăng luồng tác vụ trở nên nhanh hơn (không sao), nhưng luồng song song vẫn quá chậm. Chuyện gì vậy? Lưu ý: bạn có collect(Collectors.toList())trong bạn lệnh. Thu thập vào bộ sưu tập duy nhất về cơ bản giới thiệu nút cổ chai hiệu năng và chi phí chung trong trường hợp thực hiện đồng thời. Có thể ước tính chi phí tương đối bằng cách thay thế

collecting to collection -> counting the element count

Đối với các luồng nó có thể được thực hiện bởi collect(Collectors.counting()). Tôi đã có kết quả:

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)

Đó là một nhiệm vụ lớn! ( int max = 10000000) Kết luận: việc thu thập vật phẩm để thu thập chiếm phần lớn thời gian. Phần chậm nhất là thêm vào danh sách. BTW, đơn giản ArrayListđược sử dụng cho Collectors.toList().


Bạn cần microbenchmark bài kiểm tra này, có nghĩa là nó nên được làm nóng đầu tiên rất nhiều lần, sau đó thực hiện rất nhiều tmes và tính trung bình.
skiwi

@skiwi chắc chắn, bạn nói đúng, đặc biệt vì có độ lệch lớn trong các phép đo. Tôi chỉ thực hiện điều tra cơ bản và không giả vờ kết quả chính xác.
Serge Fedorov

JIT trong chế độ máy chủ, khởi động sau 10k thực thi. Và sau đó phải mất một thời gian để biên dịch mã và trao đổi nó.
pveentjer

Về câu này: " bạn có collect(Collectors.toList())trong lệnh của bạn, tức là có thể có một tình huống khi bạn cần xử lý Bộ sưu tập duy nhất theo nhiều luồng. " Tôi gần như chắc chắn rằng toListthu thập song song một số trường hợp danh sách khác nhau . Chỉ khi bước cuối cùng trong bộ sưu tập, các phần tử được chuyển sang một danh sách và sau đó được trả về. Vì vậy, không nên đồng bộ hóa trên đầu. Đây là lý do tại sao các nhà sưu tập có cả nhà cung cấp, người tích lũy và chức năng kết hợp. (Tất nhiên là có thể chậm vì những lý do khác.)
Lii 7/07/2016

@Lii Tôi nghĩ cách tương tự về collectviệc thực hiện ở đây. Nhưng cuối cùng, một số danh sách nên được hợp nhất thành một danh sách duy nhất và có vẻ như hợp nhất là hoạt động nặng nhất trong ví dụ đã cho.
Serge 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);
}

Tôi thay đổi mã một chút, chạy trên mac book pro của tôi có 8 lõi, tôi nhận được kết quả hợp lý:

Bộ sưu tập: Thời gian đã trôi qua: 1522036826 ns (1.522037 giây)

Luồng: Thời gian đã trôi qua: 4315833719 ns (4.315834 giây)

Luồng song song: Thời gian đã trôi qua: 261152901 ns (0,261153 giây)


Tôi nghĩ thử nghiệm của bạn là công bằng, bạn chỉ cần một máy có nhiều lõi cpu hơn.
Mellon

3

Đối với những gì bạn đang cố gắng làm, tôi sẽ không sử dụng java api thông thường. Có một tấn quyền anh / unboxing đang diễn ra, vì vậy có một hiệu suất rất lớn trên đầu.

Cá nhân tôi nghĩ rằng rất nhiều API được thiết kế là tào lao vì chúng tạo ra rất nhiều đối tượng.

Hãy thử sử dụng một mảng nguyên thủy của double / int và thử thực hiện nó theo từng luồng và xem hiệu suất là gì.

Tái bút: Bạn có thể muốn có một cái nhìn về JMH để đảm nhận việc làm điểm chuẩn. Nó quan tâm đến một số cạm bẫy điển hình như làm nóng JVM.


LinkedLists thậm chí còn tệ hơn ArrayLists vì bạn cần tạo tất cả các đối tượng nút. Toán tử mod cũng là con chó chậm. Tôi tin rằng một cái gì đó như 10/15 chu kỳ + nó làm cạn kiệt đường ống chỉ dẫn. Nếu bạn muốn thực hiện phép chia rất nhanh cho 2, chỉ cần dịch chuyển bit số 1 sang phải. Đây là những thủ thuật cơ bản, nhưng tôi chắc chắn có những thủ thuật nâng cao chế độ để tăng tốc mọi thứ, nhưng có lẽ đây là những vấn đề cụ thể hơn.
pveentjer

Tôi biết về quyền anh. Đây chỉ là một điểm chuẩn không chính thức. Ý tưởng là có cùng số lượng đấm bốc / không hộp trong cả bộ sưu tập và các bài kiểm tra luồng.
Smith

Đầu tiên tôi sẽ đảm bảo rằng nó không đo lường sai lầm. Hãy thử chạy điểm chuẩn một vài lần trước khi bạn thực hiện điểm chuẩn thực sự. Sau đó, ít nhất bạn đã khởi động JVM và mã được JITTED chính xác. Nếu không có điều này, bạn có thể đưa ra kết luận sai.
pveentjer

Ok, tôi sẽ đăng kết quả mới theo lời khuyên của bạn. Tôi đã xem JMH nhưng nó yêu cầu Maven và phải mất một thời gian để cấu hình. Dẫu sao cũng xin cảm ơn.
Smith

Tôi nghĩ rằng tốt nhất là tránh nghĩ về các bài kiểm tra điểm chuẩn theo nghĩa "Đối với những gì bạn đang cố gắng làm." tức là, thông thường các loại bài tập này được đơn giản hóa đủ để có thể được chứng minh, nhưng đủ phức tạp để chúng trông giống như chúng có thể / nên được đơn giản hóa.
ry Bổ trợ
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.