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


233

У мене є список 500000 випадково згенерованих Tuple<long,long,string>об'єктів, на яких я здійснюю простий пошук "між":

var data = new List<Tuple<long,long,string>>(500000);
...
var cnt = data.Count(t => t.Item1 <= x && t.Item2 >= x);

Коли я генерую свій випадковий масив і запускаю пошук 100 випадково генерованих значень x, пошук завершується приблизно за чотири секунди. Знаючи про чудеса, які сортування робить для пошуку , однак я вирішив сортувати свої дані - спочатку Item1, потім Item2, і нарешті Item3- перед тим, як запустити мої 100 пошукових запитів. Я очікував, що відсортована версія буде виконуватись трохи швидше через передбачення гілок: я міркував, що як тільки ми дістанемося до того, коли Item1 == xвсі подальші перевірки t.Item1 <= xпрогнозуватимуть гілку як «не брати», прискоривши хвостову частину пошук. На мій подив, пошуки займали вдвічі більше часу на відсортованому масиві !

Я спробував змінити порядок, в якому я проводив свої експерименти, і використовував різні насіння для генератора випадкових чисел, але ефект був однаковий: пошуки в несортованому масиві проходили майже вдвічі швидше, ніж пошуки в одному масиві, але сортував!

Хтось має хороше пояснення цього дивного ефекту? Наступний вихідний код моїх тестів; Я використовую .NET 4.0.


private const int TotalCount = 500000;
private const int TotalQueries = 100;
private static long NextLong(Random r) {
    var data = new byte[8];
    r.NextBytes(data);
    return BitConverter.ToInt64(data, 0);
}
private class TupleComparer : IComparer<Tuple<long,long,string>> {
    public int Compare(Tuple<long,long,string> x, Tuple<long,long,string> y) {
        var res = x.Item1.CompareTo(y.Item1);
        if (res != 0) return res;
        res = x.Item2.CompareTo(y.Item2);
        return (res != 0) ? res : String.CompareOrdinal(x.Item3, y.Item3);
    }
}
static void Test(bool doSort) {
    var data = new List<Tuple<long,long,string>>(TotalCount);
    var random = new Random(1000000007);
    var sw = new Stopwatch();
    sw.Start();
    for (var i = 0 ; i != TotalCount ; i++) {
        var a = NextLong(random);
        var b = NextLong(random);
        if (a > b) {
            var tmp = a;
            a = b;
            b = tmp;
        }
        var s = string.Format("{0}-{1}", a, b);
        data.Add(Tuple.Create(a, b, s));
    }
    sw.Stop();
    if (doSort) {
        data.Sort(new TupleComparer());
    }
    Console.WriteLine("Populated in {0}", sw.Elapsed);
    sw.Reset();
    var total = 0L;
    sw.Start();
    for (var i = 0 ; i != TotalQueries ; i++) {
        var x = NextLong(random);
        var cnt = data.Count(t => t.Item1 <= x && t.Item2 >= x);
        total += cnt;
    }
    sw.Stop();
    Console.WriteLine("Found {0} matches in {1} ({2})", total, sw.Elapsed, doSort ? "Sorted" : "Unsorted");
}
static void Main() {
    Test(false);
    Test(true);
    Test(false);
    Test(true);
}

Populated in 00:00:01.3176257
Found 15614281 matches in 00:00:04.2463478 (Unsorted)
Populated in 00:00:01.3345087
Found 15614281 matches in 00:00:08.5393730 (Sorted)
Populated in 00:00:01.3665681
Found 15614281 matches in 00:00:04.1796578 (Unsorted)
Populated in 00:00:01.3326378
Found 15614281 matches in 00:00:08.6027886 (Sorted)

15
Через галузеве передбачення: p
Soner Gönül

8
@jalf Я очікував, що сортована версія буде працювати трохи швидше через передбачення галузей. Моє думка полягала в тому, що як тільки ми дістанемося до того Item1 == x, що всі подальші перевірки t.Item1 <= xстануть правильно передбачити гілку як «не приймати», прискоривши хвостову частину пошуку. Очевидно, що ця думка була доведена неправильною суворою реальністю :)
dasblinkenlight

1
@ChrisSinclair гарне спостереження! Я додав пояснення у свою відповідь.
usr

39
Це запитання НЕ дублікат існуючого тут питання. Не голосуйте, щоб закрити її як єдину.
ThiefMaster

2
@ Sar009 Зовсім не! Два питання розглядають два дуже різні сценарії, цілком природно, що доходять різні результати.
dasblinkenlight

Відповіді:


269

Коли ви використовуєте несортований список, всі кортежі мають доступ у порядку пам'яті . Вони розподіляються послідовно в оперативній пам'яті. Процесори люблять отримувати доступ до пам'яті послідовно, тому що вони можуть спекулятивно запитувати наступну рядок кешу, тому він завжди буде присутній, коли це потрібно.

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

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

Штраф із кеш-помилки перевищує збережений штраф передбачення гілки в цьому випадку.

Спробуйте перейти на struct-tuple. Це відновить працездатність, тому що для виконання членів кортежу не потрібно виникати перенаправлення покажчиків під час виконання.

Кріс Сінклер в коментарях зазначає, що "для TotalCount близько 10 000 або менше сортована версія працює швидше ". Це тому, що невеликий список повністю вписується в кеш процесора . Доступ до пам'яті може бути непередбачуваним, але ціль завжди знаходиться в кеші. Я вважаю, що ще існує невеликий штраф, тому що навіть навантаження з кешу вимагає певних циклів. Але це, здається, не є проблемою, оскільки процесор може перемикати кілька непогашених навантажень , тим самим збільшуючи пропускну здатність. Щоразу, коли процесор натисне очікування на пам'ять, він все одно буде просуватися в потоці інструкцій, щоб встановити в чергу якомога більше операцій з пам'яттю. Цей прийом використовується для приховування затримок.

Така поведінка показує, як важко передбачити продуктивність на сучасних процесорах. Той факт, що ми лише в 2 рази повільніше, переходячи від послідовного доступу до випадкової пам'яті, підкаже мені, скільки відбувається під кришками, щоб приховати затримку пам’яті. Доступ до пам'яті може зупинити ЦП протягом 50-200 циклів. Враховуючи, що номер один може очікувати, що програма стане> в 10 разів повільнішою при введенні випадкових доступу до пам'яті.


5
Важлива причина, чому все, що ви дізнаєтесь на C / C ++, не застосовується дослівно до такої мови, як C #!
користувач541686

37
Ви можете підтвердити цю поведінку, вручну скопіювавши відсортовані дані окремо, new List<Tuple<long,long,string>>(500000)перш ніж тестувати цей новий список. У цьому сценарії відсортований тест настільки ж швидкий, як і несортований, що збігається з міркуванням цієї відповіді.
Бобсон

3
Відмінно, дуже дякую! Я зробив еквівалентну Tupleструктуру, і програма почала вести себе так, як я передбачила: відсортована версія була трохи швидшою. Більше того, несортована версія стала вдвічі швидшою! Таким чином, цифри з struct2s несортовані проти 1.9s відсортовані.
dasblinkenlight

2
Тож чи можемо ми зробити з цього висновок, що кеш-міс завдає шкоди більше, ніж неправильне передбачення? Я так думаю і завжди так думав. У C ++ std::vectorмайже завжди працює краще, ніж std::list.
Наваз

3
@Mehrdad: Ні. Це справедливо і для C ++. Навіть у C ++ компактні структури даних швидкі. Уникнення кеш-пропуску так само важливо на C ++, як і на будь-якій іншій мові. std::vectorvs std::list- хороший приклад.
Наваз

4

LINQ не знає, сортуєте ви список чи ні.

Оскільки параметр Count with preicate є методом розширення для всіх IEnumerables, я думаю, він навіть не знає, чи працює він над колекцією з ефективним випадковим доступом. Отже, він просто перевіряє кожен елемент, і Usr пояснив, чому продуктивність знизилася.

Щоб використовувати переваги продуктивності відсортованого масиву (наприклад, двійковий пошук), вам доведеться зробити трохи більше кодування.


5
Я думаю, що ви неправильно зрозуміли питання: звичайно, я не сподівався, що Countабо Where"якимось чином" підхоплюю думку про те, що мої дані сортуються, і замість простого "перевірити все" пошук у двійковому пошуку. Все, на що я сподівався, було певне поліпшення завдяки кращому прогнозуванню галузей (див. Посилання всередині мого питання), але, як виявляється, місцевість опорних козирів прогнозує галузь великий час.
dasblinkenlight
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.