Чому обробка відсортованого масиву * повільніша *, ніж невідсортованого масиву? (Java's ArrayList.indexOf)


80

Заголовок посилається на Чому швидше обробляти відсортований масив, ніж невідсортований масив?

Це також ефект прогнозування галузей? Обережно: тут виконується обробка відсортованого масиву повільніше !!

Розглянемо такий код:

private static final int LIST_LENGTH = 1000 * 1000;
private static final long SLOW_ITERATION_MILLIS = 1000L * 10L;

@Test
public void testBinarySearch() {
    Random r = new Random(0);
    List<Double> list = new ArrayList<>(LIST_LENGTH);
    for (int i = 0; i < LIST_LENGTH; i++) {
        list.add(r.nextDouble());
    }
    //Collections.sort(list);
    // remove possible artifacts due to the sorting call
    // and rebuild the list from scratch:
    list = new ArrayList<>(list);

    int nIterations = 0;
    long startTime = System.currentTimeMillis();
    do {
        int index = r.nextInt(LIST_LENGTH);
        assertEquals(index, list.indexOf(list.get(index)));
        nIterations++;
    } while (System.currentTimeMillis() < startTime + SLOW_ITERATION_MILLIS);
    long duration = System.currentTimeMillis() - startTime;
    double slowFindsPerSec = (double) nIterations / duration * 1000;
    System.out.println(slowFindsPerSec);

    ...
}

Це виводить на мою машину значення близько 720.

Тепер, якщо я активую виклик сортування колекцій, це значення падає до 142. Чому?!?

Результати є остаточними, вони не зміняться , якщо я збільшити число ітерацій / час.

Версія Java - 1.8.0_71 (Oracle VM, 64 біт), працює під управлінням Windows 10, тест JUnit в Eclipse Mars.

ОНОВЛЕННЯ

Здається, це пов’язано із суміжним доступом до пам’яті (подвійні об’єкти, доступ до яких здійснюється в послідовному порядку проти випадкового). Ефект для мене починає зникати при довжині масиву близько 10 000 і менше.

Дякуємо асиліям за надання результатів :

/**
 * Benchmark                     Mode  Cnt  Score   Error  Units
 * SO35018999.shuffled           avgt   10  8.895 ± 1.534  ms/op
 * SO35018999.sorted             avgt   10  8.093 ± 3.093  ms/op
 * SO35018999.sorted_contiguous  avgt   10  1.665 ± 0.397  ms/op
 * SO35018999.unsorted           avgt   10  2.700 ± 0.302  ms/op
 */



3
Повторіть свої вимірювання за допомогою відповідної системи порівняльних показників, як JMH, якщо ви хочете мати вагомі результати.
Clashsoft

7
Крім того, навіть без JMH ваш метод тестування є концептуально недосконалим. Ви тестуєте всілякі речі, включаючи RNG System.currentTimeMillis та assertEquals. Ітерацій розминки немає, ітерацій загалом немає, ви покладаєтесь на постійну кількість часу і перевіряєте, скільки було зроблено за цей час. Вибачте, але цей тест фактично марний.
Clashsoft

4
Отримання подібних результатів з jmh ...
assylias

Відповіді:


88

Це схоже на ефект кешування / попереднього завантаження.

Підказка полягає в тому, що ви порівнюєте подвійні (об'єкти), а не подвійні (примітиви). Коли ви розподіляєте об'єкти в одному потоці, вони зазвичай виділяються послідовно в пам'яті. Отже, коли indexOfсканує список, він проходить через послідовні адреси пам'яті. Це добре для евристики попереднього отримання кешу процесора.

Але після того, як ви відсортуєте список, вам все одно доведеться виконати однакову кількість запитів пам'яті в середньому, але цього разу доступ до пам'яті буде в довільному порядку.

ОНОВЛЕННЯ

Ось еталон, щоб довести, що порядок виділених об’єктів має значення.

Benchmark            (generator)  (length)  (postprocess)  Mode  Cnt  Score   Error  Units
ListIndexOf.indexOf       random   1000000           none  avgt   10  1,243 ± 0,031  ms/op
ListIndexOf.indexOf       random   1000000           sort  avgt   10  6,496 ± 0,456  ms/op
ListIndexOf.indexOf       random   1000000        shuffle  avgt   10  6,485 ± 0,412  ms/op
ListIndexOf.indexOf   sequential   1000000           none  avgt   10  1,249 ± 0,053  ms/op
ListIndexOf.indexOf   sequential   1000000           sort  avgt   10  1,247 ± 0,037  ms/op
ListIndexOf.indexOf   sequential   1000000        shuffle  avgt   10  6,579 ± 0,448  ms/op

2
Якщо це правда, перетасовка замість сортування повинна дати той самий результат
Девід Сороко

1
@DavidSoroko це робить.
assylias

1
@DavidSoroko Повні результати тесту з невідсортованими, перемішаними, відсортованими та відсортованими суміжними внизу коду базового тесту .
assylias

1
@assylias Цікавим розширенням може бути також створення послідовних номерів (і розміщення отриманого коду тут зробить мою відповідь застарілою).
Marco13

1
Тільки підкреслимо, list.indexOf(list.get(index))що list.get(index)в жодному разі не виграє попереднє завантаження, оскільки indexце випадково. Ціна list.get(index)однакова, незалежно від погоди, список був відсортований чи ні. Попереднє вилучення ударів лише дляlist.indexOf()
Девід Сороко

25

Я думаю, що ми спостерігаємо ефект пропусків кешу пам’яті:

При створенні невідсортованого списку

for (int i = 0; i < LIST_LENGTH; i++) {
    list.add(r.nextDouble());
}

всі подвійні, швидше за все, розміщені у суміжній області пам'яті. Ітерація через це призведе до декількох помилок кешу.

З іншого боку, у відсортованому списку посилання хаотично вказують на пам’ять.

Тепер, якщо ви створюєте відсортований список із суміжною пам'яттю:

Collection.sort(list);
List<Double> list2 = new ArrayList<>();
for (int i = 0; i < LIST_LENGTH; i++) {
    list2.add(new Double(list.get(i).doubleValue()));
}

цей відсортований список має таку ж ефективність, як і оригінальний (мої терміни).


8

Як простий приклад, який підтверджує відповідь wero та відповідь apangin (+1!): Наступне робить просте порівняння обох варіантів:

  • Створення випадкових чисел та їх сортування за бажанням
  • Створення послідовних чисел та їх переміщення за бажанням

Він також не реалізований як еталон JMH, але подібний до оригінального коду, лише з невеликими змінами для спостереження ефекту:

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;

public class SortedListTest
{
    private static final long SLOW_ITERATION_MILLIS = 1000L * 3L;

    public static void main(String[] args)
    {
        int size = 100000;
        testBinarySearchOriginal(size, true);
        testBinarySearchOriginal(size, false);
        testBinarySearchShuffled(size, true);
        testBinarySearchShuffled(size, false);
    }

    public static void testBinarySearchOriginal(int size, boolean sort)
    {
        Random r = new Random(0);
        List<Double> list = new ArrayList<>(size);
        for (int i = 0; i < size; i++)
        {
            list.add(r.nextDouble());
        }
        if (sort)
        {
            Collections.sort(list);
        }
        list = new ArrayList<>(list);

        int count = 0;
        int nIterations = 0;
        long startTime = System.currentTimeMillis();
        do
        {
            int index = r.nextInt(size);
            if (index == list.indexOf(list.get(index)))
            {
                count++;
            }
            nIterations++;
        }
        while (System.currentTimeMillis() < startTime + SLOW_ITERATION_MILLIS);
        long duration = System.currentTimeMillis() - startTime;
        double slowFindsPerSec = (double) nIterations / duration * 1000;

        System.out.printf("Size %8d sort %5s iterations %10.3f count %10d\n",
            size, sort, slowFindsPerSec, count);
    }

    public static void testBinarySearchShuffled(int size, boolean sort)
    {
        Random r = new Random(0);
        List<Double> list = new ArrayList<>(size);
        for (int i = 0; i < size; i++)
        {
            list.add((double) i / size);
        }
        if (!sort)
        {
            Collections.shuffle(list);
        }
        list = new ArrayList<>(list);

        int count = 0;
        int nIterations = 0;
        long startTime = System.currentTimeMillis();
        do
        {
            int index = r.nextInt(size);
            if (index == list.indexOf(list.get(index)))
            {
                count++;
            }
            nIterations++;
        }
        while (System.currentTimeMillis() < startTime + SLOW_ITERATION_MILLIS);
        long duration = System.currentTimeMillis() - startTime;
        double slowFindsPerSec = (double) nIterations / duration * 1000;

        System.out.printf("Size %8d sort %5s iterations %10.3f count %10d\n",
            size, sort, slowFindsPerSec, count);
    }

}

Результат на моїй машині:

Size   100000 sort  true iterations   8560,333 count      25681
Size   100000 sort false iterations  19358,667 count      58076
Size   100000 sort  true iterations  18554,000 count      55662
Size   100000 sort false iterations   8845,333 count      26536

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

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.