> проти> = у сортуванні міхурів спричиняє значну різницю в продуктивності


76

Я просто щось натрапив. Спочатку я думав, що це може бути випадком непередбачення гілок, як у цьому випадку , але я не можу пояснити, чому непередбачення гілок повинно спричинити таку поведінку.

Я реалізував дві версії Bubble Sort на Java і провів тестування продуктивності:

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

Як бачимо, єдина різниця між цими двома методами сортування - >проти >=. При запуску програми зjava BubbleSortAnnomaly 50000 10 10 , очевидно, можна очікувати, що sortBце повільніше, ніж sortAтому, що вона повинна виконати більше swap(...)s. Але я отримав такий (або подібний) результат на трьох різних машинах:

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.

Коли я встановлюю параметр для LIMIT , наприклад, 50000( java BubbleSortAnnomaly 50000 50000 10), я отримую очікувані результати:

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

Я переніс програму на C ++, щоб визначити, чи є ця проблема специфічною для Java. Ось код С ++.

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

Ця програма демонструє однакову поведінку. Хтось може пояснити, що саме тут відбувається?

Виконання sortBспочатку, а потім sortAне змінює результати.


1
Як ти вимірював час? Якщо виміряти час лише для одного випадку, час буде сильно залежати від випадкових послідовностей, і >vs >=матиме лише незначний вплив. Щоб отримати дійсно значущі числа, потрібно виміряти багато різних послідовностей та середнє значення
greatest_prime_is_463035818

@ tobi303 подивіться на код. Ви можете запустити його в циклі через 3-й параметр виконання (Java) або -DRUNS=XXX(C ++, директива компілятора). І результати відтворювані.
Тюрінг85,

було б цікаво підрахувати кількість свопів в обох випадках, щоб побачити, як це пов’язано з часом роботи. Я маю на увазі, що якщо A працює повільніше, це точно не через кількість свопів, тож, можливо, у випадку, якщо A швидше, причина також не просто кількість свопів, а деякі більш тонкі ефекти
найбільший_пример_is_463035818

@ Turing85: Але ти повторив тест?
user2357112 підтримує Моніку

Було б також цікаво подивитися, чи результати тримаються при bubbleSortB()першому, а потім дзвінку bubbleSortA(). З Java я часто підозрюю, що виділення пам'яті є, а gc викликає несподівані результати. Хоча отримання тих самих результатів у C ++ могло б припустити, що тут відбувається щось більш загальне.
Кевін Кондон

Відповіді:


45

Думаю, це справді може бути пов’язано із прогнозуванням галузі. Якщо підрахувати кількість свопів порівняно з кількістю внутрішніх ітерацій сортування, які ви знайдете:

Обмеження = 10

  • A = 560M свопи / 1250M петлі
  • B = 1250M свопи / 1250M петлі (0,02% менше свопів, ніж петлі)

Обмеження = 50000

  • A = 627M свопи / 1250M петлі
  • B = 850M свопи / 1250M петлі

Отже, у Limit == 10випадку, коли обмін виконується 99,98% часу у сорту B, що, очевидно, є сприятливим для прогнозування відгалуження. У тому Limit == 50000випадку, якщо своп потрапляє лише випадково на 68%, тому прогноз гілки є менш вигідним.


2
Ваш аргумент здається розумним. Чи є спосіб перевірити свою гіпотезу?
Тюрінг85,

1
Швидка відповідь полягає в тому, щоб керувати вхідними масивами чимось таким, щоб сорти для A / B виконували однакові обміни в однаковому порядку (принаймні приблизно). Як саме це робити, я не знаю. Ви також можете подивитися, наскільки випадковим є порядок обміну "якимось чином", і побачити, чи це відповідає часу сортування.
UESP

1
Для випадків, коли LIMIT >= ARRAY_SIZEви можете зробити тест, коли масив складається з унікальних чисел. Наприклад, у випадку, коли a[i] = ARRAY_SIZE - iви отримуєте обмін по кожному циклу та однаковий час для сортів A / B.
uesp

@ Turing85, зверніть увагу, що моя відповідь насправді пояснює, чому така різниця у кількості свопів.
Петро

@Petr, чому існує більша кількість обмінів, для мене було очевидним. Я просто не зміг співвіднести цей факт із непередбачуванням галузі. І обрана відповідь дала (на мій погляд) найкраще пояснення з найкращою аргументацією.
Тюрінг85,

11

Думаю, це справді можна пояснити неправильним прогнозуванням галузі.

Розглянемо, наприклад, LIMIT = 11 і sortB. На першій ітерації зовнішнього циклу він дуже швидко наткнеться на один з елементів, рівний 10. Отже, він матиме a[j]=10, а отже, точно a[j]буде >=a[next], оскільки немає елементів, що перевищують 10. Тому він виконає обмін , то зробіть лише один крок, jщоб лише це знайтиa[j]=10 ще раз (те саме замінене значення). Так ще раз буде a[j]>=a[next], і так одне. Кожне порівняння, крім кількох на самому початку, буде вірним. Подібним чином він буде працювати на наступних ітераціях зовнішнього циклу.

Не те саме для sortA. Почнеться приблизно так само, натрапить a[j]=10, зробіть кілька обмінів подібним чином, але лише до того моменту, коли він a[next]=10теж знайде . Тоді умова буде хибною, і обмін не буде здійснено. І так далі: кожного разу, коли він натрапляє a[next]=10, умова хибна, і заміни не виконуються. Отже, ця умова відповідає дійсності 10 разів з 11 (значення a[next]від 0 до 9), а помилково - в 1 випадку з 11. Нічого дивного в тому, що передбачення гілок не вдається.


9

Використовуючи наданий код C ++ (підрахунок часу видалено) з perf statкомандою, я отримав результати, які підтверджують теорію брач-міс.

З Limit = 10, BubbleSortB високою вигоди від пророкування розгалужень (0,01%) , але не потрапляє з Limit = 50000пророкування розгалужень зазнає невдачі навіть більше (з 15,65% промахів) , ніж в BubbleSortA (12.69% і 12.76% промаху відповідно).

Обмеження 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

Обмеження 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

Обмеження 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

Обмеження 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

Редагування 2: Ця відповідь, мабуть, неправильна в більшості випадків, нижча, коли я кажу, що все вище правильно, все одно відповідає дійсності, але нижня частина не відповідає дійсності для більшості архітектур процесорів, див. Коментарі. Однак я скажу, що теоретично все-таки можливо, що в певній ОС / архітектурі існує деякий JVM, який це робить, але що JVM, мабуть, погано реалізований або це дивна архітектура. Крім того, це теоретично можливо в тому сенсі, що теоретично можливо більшість можливих речей, тому останню порцію я б взяв із зерном солі.

По-перше, я не впевнений у C ++, але я можу поговорити про Java.

Ось деякий код,

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

Запускаючи javap -cна ньому, я отримую байт-код

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
}

Ви помітите, що єдина різниця if_icmpge(якщо порівнювати більше / рівне) протиif_icmpgt (якщо порівнювати більше).

Все вище - це факт, решта - це моє найкраще здогадування щодо того, як if_icmpgeі if_icmpgtз чим обробляються на основі курсу коледжу, який я пройшов на мові асамблеї. Щоб отримати кращу відповідь, слід переглянути, як ваш JVM з ними справляється. Я припускаю, що C ++ також компілюється до подібної операції.

Edit: Документація на if_i<cond>це тут

Те , як комп'ютери , порівнювати числа віднімає одне з іншого і перевірка , якщо це число 0 або немає, тому при виконанні a < bякщо віднімає bз aі бачить , якщо результат менше 0, перевіряючи знак значення ( b - a < 0). Для цього a <= bпотрібно зробити додатковий крок і відняти 1 (b - a - 1 < 0 ).

Зазвичай це дуже незначна різниця, але це не будь-який код, це дивовижне сортування міхурів! O (n ^ 2) - це середня кількість разів, коли ми робимо це конкретне порівняння, оскільки воно знаходиться у самому внутрішньому циклі.

Так, це може мати щось спільне з прогнозуванням галузей. Я не впевнений, я не фахівець у цьому, але я думаю, що це також може зіграти несуттєву роль.


Я не думаю, що ви правильно сказали про <те, що швидше ніж <=. Інструкції процесора дискретні; кожна інструкція повинна приймати ціле число тактових циклів - немає "економії часу", якщо ви не можете вичавити з неї цілий годинник. Див stackoverflow.com/a/12135533
kevinsa5

Зверніть увагу, що я говорю лише про власний код. Я припускаю, що можливо, що реалізація JVM може виконати цю "оптимізацію", але я гадаю, що вона буде просто використовувати власні інструкції, а не готувати власне рішення. Але це лише здогадки.
kevinsa5,

4
На чому ви базуєте своє твердження, що <= використовує додатковий крок для віднімання зайвого 1? Наприклад, на рівні x86 cmpнаступний за ним jlатрибут займе рівно стільки ж часу, якщо це дозволить прогнозування успішного відгалуження, за cmpяким слідує a jle. stackoverflow.com/questions/12135518/is-faster-than має більше деталей.
ClickRick

@ClickRick Асамблея, яку я дізнався, була для SPARC, яка використовувала зменшений набір інструкцій. Може, цього не було jle? А може, я десь теж чув це хибне припущення. Не впевнений на 100%, звідки я це взяв зараз, що справді вважаю. Я припускаю, що теоретично хоч те, як інтерпретувала це будь-яка окрема ОС / Архітектура, може мати певне значення, але тепер я вважаю, що всі вони роблять це за один цикл.
Капітан Мен

2
@CaptainMan Відповідно до cs.northwestern.edu/~agupta/_projects/sparc_simulator/..., SPARC підтримує і те, blі інше ble, що для мене абсолютно не дивно.
ClickRick
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.