> so với> = trong sắp xếp bong bóng gây ra sự khác biệt hiệu suất đáng kể


76

Tôi vừa vấp phải một cái gì đó. Lúc đầu, tôi nghĩ rằng nó có thể là một trường hợp phân tích sai nhánh giống như trong trường hợp này , nhưng tôi không thể giải thích tại sao việc phân tích sai chi nhánh lại gây ra hành vi này.

Tôi đã triển khai hai phiên bản Bubble Sort trong Java và đã thực hiện một số thử nghiệm hiệu suất:

import java.util.Random;

public class BubbleSortAnnomaly {

    public static void main(String... args) {
        final int ARRAY_SIZE = Integer.parseInt(args[0]);
        final int LIMIT = Integer.parseInt(args[1]);
        final int RUNS = Integer.parseInt(args[2]);

        int[] a = new int[ARRAY_SIZE];
        int[] b = new int[ARRAY_SIZE];
        Random r = new Random();
        for (int run = 0; RUNS > run; ++run) {
            for (int i = 0; i < ARRAY_SIZE; i++) {
                a[i] = r.nextInt(LIMIT);
                b[i] = a[i];
            }

            System.out.print("Sorting with sortA: ");
            long start = System.nanoTime();
            int swaps = bubbleSortA(a);

            System.out.println(  (System.nanoTime() - start) + " ns. "
                               + "It used " + swaps + " swaps.");

            System.out.print("Sorting with sortB: ");
            start = System.nanoTime();
            swaps = bubbleSortB(b);

            System.out.println(  (System.nanoTime() - start) + " ns. "
                               + "It used " + swaps + " swaps.");
        }
    }

    public static int bubbleSortA(int[] a) {
        int counter = 0;
        for (int i = a.length - 1; i >= 0; --i) {
            for (int j = 0; j < i; ++j) {
                if (a[j] > a[j + 1]) {
                    swap(a, j, j + 1);
                    ++counter;
                }
            }
        }
        return (counter);
    }

    public static int bubbleSortB(int[] a) {
        int counter = 0;
        for (int i = a.length - 1; i >= 0; --i) {
            for (int j = 0; j < i; ++j) {
                if (a[j] >= a[j + 1]) {
                    swap(a, j, j + 1);
                    ++counter;
                }
            }
        }
        return (counter);
    }

    private static void swap(int[] a, int j, int i) {
        int h = a[i];
        a[i] = a[j];
        a[j] = h;
    }
}

Như chúng ta có thể thấy, sự khác biệt duy nhất giữa hai phương pháp sắp xếp là >vs >=. Khi chạy chương trình với java BubbleSortAnnomaly 50000 10 10, rõ ràng người ta sẽ mong đợi điều đó sortBchậm hơn sortAvì nó phải thực thi nhiều swap(...)s hơn . Nhưng tôi nhận được kết quả sau (hoặc tương tự) trên ba máy khác nhau:

Sorting with sortA: 4.214 seconds. It used  564960211 swaps.
Sorting with sortB: 2.278 seconds. It used 1249750569 swaps.
Sorting with sortA: 4.199 seconds. It used  563355818 swaps.
Sorting with sortB: 2.254 seconds. It used 1249750348 swaps.
Sorting with sortA: 4.189 seconds. It used  560825110 swaps.
Sorting with sortB: 2.264 seconds. It used 1249749572 swaps.
Sorting with sortA: 4.17  seconds. It used  561924561 swaps.
Sorting with sortB: 2.256 seconds. It used 1249749766 swaps.
Sorting with sortA: 4.198 seconds. It used  562613693 swaps.
Sorting with sortB: 2.266 seconds. It used 1249749880 swaps.
Sorting with sortA: 4.19  seconds. It used  561658723 swaps.
Sorting with sortB: 2.281 seconds. It used 1249751070 swaps.
Sorting with sortA: 4.193 seconds. It used  564986461 swaps.
Sorting with sortB: 2.266 seconds. It used 1249749681 swaps.
Sorting with sortA: 4.203 seconds. It used  562526980 swaps.
Sorting with sortB: 2.27  seconds. It used 1249749609 swaps.
Sorting with sortA: 4.176 seconds. It used  561070571 swaps.
Sorting with sortB: 2.241 seconds. It used 1249749831 swaps.
Sorting with sortA: 4.191 seconds. It used  559883210 swaps.
Sorting with sortB: 2.257 seconds. It used 1249749371 swaps.

Khi tôi đặt tham số cho LIMITthành, ví dụ: 50000( java BubbleSortAnnomaly 50000 50000 10), tôi nhận được kết quả mong đợi:

Sorting with sortA: 3.983 seconds. It used  625941897 swaps.
Sorting with sortB: 4.658 seconds. It used  789391382 swaps.

Tôi đã chuyển chương trình sang C ++ để xác định xem vấn đề này có phải do Java cụ thể hay không. Đây là mã C ++.

#include <cstdlib>
#include <iostream>

#include <omp.h>

#ifndef ARRAY_SIZE
#define ARRAY_SIZE 50000
#endif

#ifndef LIMIT
#define LIMIT 10
#endif

#ifndef RUNS
#define RUNS 10
#endif

void swap(int * a, int i, int j)
{
    int h = a[i];
    a[i] = a[j];
    a[j] = h;
}

int bubbleSortA(int * a)
{
    const int LAST = ARRAY_SIZE - 1;
    int counter = 0;
    for (int i = LAST; 0 < i; --i)
    {
        for (int j = 0; j < i; ++j)
        {
            int next = j + 1;
            if (a[j] > a[next])
            {
                swap(a, j, next);
                ++counter;
            }
        }
    }
    return (counter);
}

int bubbleSortB(int * a)
{
    const int LAST = ARRAY_SIZE - 1;
    int counter = 0;
    for (int i = LAST; 0 < i; --i)
    {
        for (int j = 0; j < i; ++j)
        {
            int next = j + 1;
            if (a[j] >= a[next])
            {
                swap(a, j, next);
                ++counter;
            }
        }
    }
    return (counter);
}

int main()
{
    int * a = (int *) malloc(ARRAY_SIZE * sizeof(int));
    int * b = (int *) malloc(ARRAY_SIZE * sizeof(int));

    for (int run = 0; RUNS > run; ++run)
    {
        for (int idx = 0; ARRAY_SIZE > idx; ++idx)
        {
            a[idx] = std::rand() % LIMIT;
            b[idx] = a[idx];
        }

        std::cout << "Sorting with sortA: ";
        double start = omp_get_wtime();
        int swaps = bubbleSortA(a);

        std::cout << (omp_get_wtime() - start) << " seconds. It used " << swaps
                  << " swaps." << std::endl;

        std::cout << "Sorting with sortB: ";
        start = omp_get_wtime();
        swaps = bubbleSortB(b);

        std::cout << (omp_get_wtime() - start) << " seconds. It used " << swaps
                  << " swaps." << std::endl;
    }

    free(a);
    free(b);

    return (0);
}

Chương trình này cho thấy cùng một hành vi. Ai đó có thể giải thích chính xác những gì đang xảy ra ở đây?

Thực hiện sortBtrước và sau đó sortAkhông thay đổi kết quả.


1
Bạn đã đo thời gian như thế nào? Nếu bạn chỉ đo thời gian cho một trường hợp, thời gian sẽ phụ thuộc rất nhiều vào các chuỗi ngẫu nhiên và >vs >=sẽ chỉ có tác động nhỏ. Để có được những con số thực sự có ý nghĩa về thời gian, bạn phải đo nhiều chuỗi khác nhau và mức trung bình
large_prime_is_463035818

@ tobi303 xem mã. Bạn có thể chạy nó trong một vòng lặp thông qua tham số thời gian chạy thứ 3 (Java) hoặc -DRUNS=XXX(C ++, chỉ thị trình biên dịch). Và kết quả có thể tái tạo.
Turing85

sẽ rất thú vị khi đếm số lần hoán đổi trong cả hai trường hợp để xem điều này liên quan như thế nào đến thời gian chạy. Ý tôi là trong trường hợp A chậm hơn, điều này chắc chắn không phải do số lần hoán đổi, vì vậy có thể trong trường hợp A nhanh hơn, lý do cũng không chỉ đơn giản là số lần hoán đổi mà là một số hiệu ứng tinh tế hơn
large_prime_is_463035818

@ Turing85: Nhưng bạn đã chạy lại bài kiểm tra?
user2357112 hỗ trợ Monica

Cũng sẽ rất thú vị khi xem kết quả có giữ nguyên khi gọi bubbleSortB()trước và sau đó hay không bubbleSortA(). Với Java, tôi thường nghi ngờ việc cấp phát bộ nhớ và gc gây ra kết quả không mong muốn. Mặc dù nhận được kết quả tương tự trong C ++ sẽ gợi ý rằng điều gì đó tổng quát hơn đang diễn ra ở đây.
Kevin Condon

Câu trả lời:


45

Tôi nghĩ rằng nó thực sự có thể là do dự đoán nhánh. Nếu bạn đếm số lần hoán đổi so với số lần lặp lại sắp xếp bên trong mà bạn tìm thấy:

Giới hạn = 10

  • A = 560M hoán đổi / 1250M vòng
  • B = 1250 triệu hoán đổi / 1250 triệu vòng (ít hơn 0,02% hoán đổi so với vòng lặp)

Giới hạn = 50000

  • A = 627M hoán đổi / 1250M vòng
  • B = 850M hoán đổi / 1250M vòng

Vì vậy, trong Limit == 10trường hợp hoán đổi được thực hiện 99,98% thời gian trong loại B, điều này rõ ràng là thuận lợi cho dự đoán nhánh. Trong Limit == 50000trường hợp hoán đổi chỉ được thực hiện ngẫu nhiên 68% vì vậy dự đoán nhánh ít có lợi hơn.


2
Lập luận của bạn có vẻ hợp lý. Có cách nào để kiểm tra giả thuyết của bạn không?
Turing85

1
Câu trả lời nhanh là điều khiển các mảng đầu vào thành một thứ gì đó sao cho sắp xếp A / B thực hiện các hoán đổi giống nhau theo cùng một thứ tự (ít nhất là gần đúng). Chính xác làm thế nào để làm điều này tôi không biết. Bạn cũng có thể xem thứ tự hoán đổi ngẫu nhiên "bằng cách nào đó" và xem điều đó có tương quan với thời gian sắp xếp hay không.
uesp

1
Đối với các trường hợp LIMIT >= ARRAY_SIZEbạn có thể thực hiện một trường hợp kiểm tra trong đó mảng được tạo bởi các số duy nhất. Ví dụ, trong trường hợp a[i] = ARRAY_SIZE - ibạn nhận được một hoán đổi trên mọi vòng lặp và thời gian giống hệt nhau cho các loại A / B.
uesp

@ Turing85, lưu ý rằng câu trả lời của tôi trên thực tế giải thích, tại sao lại có sự khác biệt về số lượng hoán đổi này.
Petr

@Petr tại sao số lượng hoán đổi lớn hơn là điều hiển nhiên đối với tôi. Tôi chỉ không thể liên hệ thực tế này với sự nhầm lẫn của chi nhánh. Và câu trả lời được chọn đã đưa ra (trong tầm mắt của tôi) lời giải thích tốt nhất với lập luận tốt nhất.
Turing85

11

Tôi nghĩ rằng điều này thực sự có thể được giải thích bởi sự sai lệch chi nhánh.

Ví dụ: hãy xem xét LIMIT = 11 và sortB. Trong lần lặp đầu tiên của vòng lặp ngoài, nó sẽ rất nhanh chóng vấp phải một trong các phần tử bằng 10. Vì vậy, nó sẽ cóa[j]=10 , và do đó chắc chắn a[j]sẽ có >=a[next], vì không có phần tử nào lớn hơn 10. Do đó, nó sẽ thực hiện hoán đổi , sau đó thực hiện một bước jchỉ để tìm lại điều đó a[j]=10một lần nữa (cùng một giá trị đã hoán đổi). Vì vậy, một lần nữa nó sẽ là a[j]>=a[next], và như vậy. Mọi so sánh ngoại trừ một số ở đầu sẽ đúng. Tương tự như vậy, nó sẽ chạy trên các lần lặp tiếp theo của vòng ngoài.

Không giống nhau cho sortA. Nó sẽ bắt đầu theo cùng một cách, tình cờ gặp a[j]=10, thực hiện một số hoán đổi theo cách tương tự, nhưng chỉ đến một điểm khi nó cũng tìm thấy a[next]=10. Khi đó điều kiện sẽ là false và không có hoán đổi nào được thực hiện. Cứ tiếp tục như vậy: mỗi khi nó vấp phải a[next]=10, điều kiện là sai và không có hoán đổi nào được thực hiện. Do đó, điều kiện này đúng 10 lần trong tổng số 11 (giá trị a[next]từ 0 đến 9), và sai 1 trường hợp trong tổng số 11. Không có gì lạ khi dự đoán nhánh không thành công.


9

Sử dụng mã C ++ được cung cấp (đã loại bỏ đếm thời gian) với perf statlệnh, tôi nhận được kết quả xác nhận lý thuyết brach-miss.

Với Limit = 10, BubbleSortB mang lại lợi ích cao từ dự đoán nhánh (bỏ lỡ 0,01%) nhưng vớiLimit = 50000 dự đoán nhánh thậm chí không thành công (với 15,65% bỏ lỡ) so với BubbleSortA (12,69% và 12,76% bỏ lỡ tương ứng).

Giới hạn BubbleSortA = 10:

Performance counter stats for './bubbleA.out':

   46670.947364 task-clock                #    0.998 CPUs utilized          
             73 context-switches          #    0.000 M/sec                  
             28 CPU-migrations            #    0.000 M/sec                  
            379 page-faults               #    0.000 M/sec                  
117,298,787,242 cycles                    #    2.513 GHz                    
117,471,719,598 instructions              #    1.00  insns per cycle        
 25,104,504,912 branches                  #  537.904 M/sec                  
  3,185,376,029 branch-misses             #   12.69% of all branches        

   46.779031563 seconds time elapsed

Giới hạn BubbleSortA = 50000:

Performance counter stats for './bubbleA.out':

   46023.785539 task-clock                #    0.998 CPUs utilized          
             59 context-switches          #    0.000 M/sec                  
              8 CPU-migrations            #    0.000 M/sec                  
            379 page-faults               #    0.000 M/sec                  
118,261,821,200 cycles                    #    2.570 GHz                    
119,230,362,230 instructions              #    1.01  insns per cycle        
 25,089,204,844 branches                  #  545.136 M/sec                  
  3,200,514,556 branch-misses             #   12.76% of all branches        

   46.126274884 seconds time elapsed

Giới hạn BubbleSortB = 10:

Performance counter stats for './bubbleB.out':

   26091.323705 task-clock                #    0.998 CPUs utilized          
             28 context-switches          #    0.000 M/sec                  
              2 CPU-migrations            #    0.000 M/sec                  
            379 page-faults               #    0.000 M/sec                  
 64,822,368,062 cycles                    #    2.484 GHz                    
137,780,774,165 instructions              #    2.13  insns per cycle        
 25,052,329,633 branches                  #  960.179 M/sec                  
      3,019,138 branch-misses             #    0.01% of all branches        

   26.149447493 seconds time elapsed

Giới hạn BubbleSortB = 50000:

Performance counter stats for './bubbleB.out':

   51644.210268 task-clock                #    0.983 CPUs utilized          
          2,138 context-switches          #    0.000 M/sec                  
             69 CPU-migrations            #    0.000 M/sec                  
            378 page-faults               #    0.000 M/sec                  
144,600,738,759 cycles                    #    2.800 GHz                    
124,273,104,207 instructions              #    0.86  insns per cycle        
 25,104,320,436 branches                  #  486.101 M/sec                  
  3,929,572,460 branch-misses             #   15.65% of all branches        

   52.511233236 seconds time elapsed

3

Chỉnh sửa 2: Câu trả lời này có lẽ sai trong hầu hết các trường hợp, thấp hơn khi tôi nói mọi thứ ở trên là đúng vẫn đúng, nhưng phần dưới không đúng với hầu hết các kiến ​​trúc bộ xử lý, hãy xem các nhận xét. Tuy nhiên, tôi sẽ nói rằng về mặt lý thuyết vẫn có thể có một số JVM trên một số Hệ điều hành / Kiến trúc thực hiện điều này, nhưng JVM đó có thể được triển khai kém hoặc đó là một kiến ​​trúc kỳ lạ. Ngoài ra, về mặt lý thuyết điều này có thể xảy ra theo nghĩa là hầu hết những điều có thể tưởng tượng được về mặt lý thuyết, vì vậy tôi sẽ lấy phần cuối cùng với một hạt muối.

Đầu tiên, tôi không chắc về C ++, nhưng tôi có thể nói một số về Java.

Đây là một số mã,

public class Example {

    public static boolean less(final int a, final int b) {
        return a < b;
    }

    public static boolean lessOrEqual(final int a, final int b) {
        return a <= b;
    }
}

Chạy javap -ctrên đó, tôi nhận được mã bytecode

public class Example {
  public Example();
    Code:
       0: aload_0
       1: invokespecial #8                  // Method java/lang/Object."<init>":()V
       4: return

  public static boolean less(int, int);
    Code:
       0: iload_0
       1: iload_1
       2: if_icmpge     7
       5: iconst_1
       6: ireturn
       7: iconst_0
       8: ireturn

  public static boolean lessOrEqual(int, int);
    Code:
       0: iload_0
       1: iload_1
       2: if_icmpgt     7
       5: iconst_1
       6: ireturn
       7: iconst_0
       8: ireturn
}

Bạn sẽ nhận thấy sự khác biệt duy nhất là if_icmpge(nếu so sánh lớn hơn / bằng) so vớiif_icmpgt (nếu so sánh lớn hơn).

Tất cả mọi thứ ở trên là sự thật, phần còn lại là phỏng đoán tốt nhất của tôi về cách if_icmpgeif_icmpgtđược xử lý dựa trên một khóa học đại học tôi đã học về hợp ngữ. Để có câu trả lời tốt hơn, bạn nên tra cứu cách JVM của bạn xử lý những điều này. Tôi đoán rằng C ++ cũng biên dịch xuống một hoạt động tương tự.

Chỉnh sửa: Tài liệu trên if_i<cond>đây

Các máy tính cách so sánh số được trừ một từ khác và kiểm tra nếu số đó là 0 hay không, vì vậy khi làm a < bnếu trừ btừ avà thấy nếu kết quả nhỏ hơn 0 bằng cách kiểm tra các dấu hiệu của giá trị ( b - a < 0). Để làm được điều a <= bđó phải thực hiện thêm một bước và trừ đi 1 (b - a - 1 < 0 ).

Thông thường đây là một sự khác biệt rất nhỏ, nhưng đây không phải là bất kỳnào , đây là loại bong bóng kỳ lạ! O (n ^ 2) là số lần trung bình chúng ta thực hiện phép so sánh cụ thể này vì nó nằm trong vòng lặp bên trong nhất.

Vâng, nó có thể liên quan đến dự đoán nhánh mà tôi không chắc, tôi không phải là chuyên gia về điều đó, nhưng tôi nghĩ điều này cũng có thể đóng một vai trò không quan trọng.


Tôi không nghĩ rằng bạn đúng về <việc nhanh hơn <=. Hướng dẫn bộ xử lý được tùy ý hóa; mỗi lệnh phải lấy một số nguyên chu kỳ đồng hồ - không có "thời gian được lưu" trừ khi bạn có thể vắt hết một đồng hồ ra khỏi nó. Xem stackoverflow.com/a/12135533
kevinsa5

Lưu ý rằng tôi chỉ nói về mã gốc. Tôi cho rằng có thể triển khai JVM có thể thực hiện "tối ưu hóa" đó, nhưng tôi đoán rằng nó sẽ chỉ sử dụng các hướng dẫn gốc thay vì tạo ra giải pháp của riêng mình. Nhưng đó chỉ là phỏng đoán.
kevinsa5

4
Bạn dựa trên cơ sở nào để khẳng định rằng <= sử dụng thêm một bước để trừ thêm 1? Ví dụ: ở cấp độ x86, cmptheo sau bởi một jlsẽ mất cùng một khoảng thời gian, cho phép dự đoán nhánh thành công, cmptheo sau là a jle. stackoverflow.com/questions/12135518/is-faster-than có thêm chi tiết.
ClickRick

@ClickRick Hợp ngữ mà tôi học được là dành cho SPARC sử dụng tập lệnh rút gọn. Có lẽ nó không có jle? Hoặc có thể tôi cũng đã nghe giả định sai lầm này ở đâu đó. Không chắc chắn 100% tôi đã nhận được cái này ở đâu bây giờ mà tôi thực sự xem xét nó. Tôi cho rằng về mặt lý thuyết, cách mà JVM của bất kỳ hệ điều hành / Kiến trúc cụ thể nào diễn giải nó có thể tạo ra sự khác biệt nào đó, nhưng bây giờ tôi cho rằng tất cả chúng đều làm điều này trong một chu kỳ duy nhất.
Captain Man

2
@CaptainMan Theo cs.northwestern.edu/~agupta/_projects/sparc_simulator/… SPARC hỗ trợ cả hai blblehướng dẫn, điều này hoàn toàn không ngạc nhiên đối với tôi.
ClickRick
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.