Величезна різниця в продуктивності (в 26 разів швидша) при компіляції для 32 та 64 біт


80

Я намагався виміряти різницю використання a forта a foreachпід час доступу до списків типів значень та типів посилань.

Я використовував наступний клас для профілювання.

public static class Benchmarker
{
    public static void Profile(string description, int iterations, Action func)
    {
        Console.Write(description);

        // Warm up
        func();

        Stopwatch watch = new Stopwatch();

        // Clean up
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();

        watch.Start();
        for (int i = 0; i < iterations; i++)
        {
            func();
        }
        watch.Stop();

        Console.WriteLine(" average time: {0} ms", watch.Elapsed.TotalMilliseconds / iterations);
    }
}

Я використовував doubleдля свого типу значення. І я створив цей "підроблений клас" для тестування посилальних типів:

class DoubleWrapper
{
    public double Value { get; set; }

    public DoubleWrapper(double value)
    {
        Value = value;
    }
}

Нарешті я запустив цей код і порівняв часові різниці.

static void Main(string[] args)
{
    int size = 1000000;
    int iterationCount = 100;

    var valueList = new List<double>(size);
    for (int i = 0; i < size; i++) 
        valueList.Add(i);

    var refList = new List<DoubleWrapper>(size);
    for (int i = 0; i < size; i++) 
        refList.Add(new DoubleWrapper(i));

    double dummy;

    Benchmarker.Profile("valueList for: ", iterationCount, () =>
    {
        double result = 0;
        for (int i = 0; i < valueList.Count; i++)
        {
             unchecked
             {
                 var temp = valueList[i];
                 result *= temp;
                 result += temp;
                 result /= temp;
                 result -= temp;
             }
        }
        dummy = result;
    });

    Benchmarker.Profile("valueList foreach: ", iterationCount, () =>
    {
        double result = 0;
        foreach (var v in valueList)
        {
            var temp = v;
            result *= temp;
            result += temp;
            result /= temp;
            result -= temp;
        }
        dummy = result;
    });

    Benchmarker.Profile("refList for: ", iterationCount, () =>
    {
        double result = 0;
        for (int i = 0; i < refList.Count; i++)
        {
            unchecked
            {
                var temp = refList[i].Value;
                result *= temp;
                result += temp;
                result /= temp;
                result -= temp;
            }
        }
        dummy = result;
    });

    Benchmarker.Profile("refList foreach: ", iterationCount, () =>
    {
        double result = 0;
        foreach (var v in refList)
        {
            unchecked
            {
                var temp = v.Value;
                result *= temp;
                result += temp;
                result /= temp;
                result -= temp;
            }
        }

        dummy = result;
    });

    SafeExit();
}

Я вибрав Releaseі Any CPUпараметри, запустив програму і отримав такі часи:

valueList for:  average time: 483,967938 ms
valueList foreach:  average time: 477,873079 ms
refList for:  average time: 490,524197 ms
refList foreach:  average time: 485,659557 ms
Done!

Потім я вибрав Release та x64, запустив програму та отримав такі часи:

valueList for:  average time: 16,720209 ms
valueList foreach:  average time: 15,953483 ms
refList for:  average time: 19,381077 ms
refList foreach:  average time: 18,636781 ms
Done!

Чому x64-розрядна версія набагато швидша? Я очікував певної різниці, але не чогось такого великого.

Я не маю доступу до інших комп’ютерів. Не могли б ви запустити це на своїх машинах і повідомити мені результати? Я використовую Visual Studio 2015 і маю Intel Core i7 930.

Ось SafeExit()метод, щоб ви могли скомпілювати / запустити самостійно:

private static void SafeExit()
{
    Console.WriteLine("Done!");
    Console.ReadLine();
    System.Environment.Exit(1);
}

За запитом, використовуючи double?замість мого DoubleWrapper:

Будь-який процесор

valueList for:  average time: 482,98116 ms
valueList foreach:  average time: 478,837701 ms
refList for:  average time: 491,075915 ms
refList foreach:  average time: 483,206072 ms
Done!

x64

valueList for:  average time: 16,393947 ms
valueList foreach:  average time: 15,87007 ms
refList for:  average time: 18,267736 ms
refList foreach:  average time: 16,496038 ms
Done!

І останнє, але не менш важливе: створення x86профілю дає мені майже однакові результати використанняAny CPU .


14
"Будь-який процесор"! = "32 біти"! Якщо компілюється "Будь-який процесор", ваша програма повинна працювати як 64-бітний процес у вашій 64-бітній системі. Також я б видалив код, що возиться з GC. Це насправді не допомагає.
Торстен Дітмар

9
@ThorstenDittmar, дзвінки GC відбуваються до вимірювання, а не в коді, що вимірюється. Це досить розумна річ, щоб зробити, щоб зменшити ступінь, до якої удача хронометражу ГХ може вплинути на таке вимірювання. Крім того, між збірками є фактор "вигода 32-розрядної версії" проти "вигода 64-розрядної версії".
Джон Ханна,

1
@ThorstenDittmar Але я запускаю випускну версію (за межами Visual Studio), і диспетчер завдань каже, що це 32-розрядна програма (при компіляції на будь-який процесор). Також. Як сказав Джон Ханна, дзвінок до ГК корисний.
Трауер

2
Яку версію середовища використання ви використовуєте? Новий RyuJIT у версії 4.6 набагато швидший, але навіть для попередніх версій компілятор x64 та JITer були новішими та вдосконаленими, ніж версії x32. Вони здатні виконувати набагато агресивнішу оптимізацію, ніж версії x86.
Панайотис Канавос

2
Я б зауважив, що задіяний тип, здається, не має ефекту; змінити doubleдо float, longабо intі ви отримаєте аналогічні результати.
Джон Ханна,

Відповіді:


87

Я можу відтворити це на 4.5.2. Тут немає RyuJIT. Розбирання x86 та x64 виглядають розумно. Перевірка дальності і так далі однакові. Та сама базова структура. Немає розгортання циклу.

x86 використовує інший набір плаваючих інструкцій. Виконання цих інструкцій здається порівнянним з інструкціями x64, за винятком розділу :

  1. 32-розрядні плаваючі інструкції x87 використовують внутрішню точність 10 байт.
  2. Розширений точний поділ надзвичайно повільний.

Операція поділу робить 32-розрядну версію надзвичайно повільною. Не коментування поділу значною мірою вирівнює продуктивність (на 32 біти - з 430 мс до 3,25 мс).

Пітер Кордес зазначає, що затримки вказівок двох одиниць із плаваючою комою не такі різні. Можливо, деякі з проміжних результатів - це денормалізовані числа або NaN. Вони можуть викликати повільний шлях в одному з блоків. Або, можливо, значення розходяться між двома реалізаціями через 10 байт проти 8 байт точності плаваючої функції.

Пітер Кордес також зазначає, що всі проміжні результати - NaN ... Видалення цієї задачі ( valueList.Add(i + 1)щоб жоден дільник не дорівнював нулю) здебільшого вирівнює результати. Очевидно, 32-розрядний код взагалі не любить операнди NaN. Давайте надрукуємо деякі проміжні значення:if (i % 1000 == 0) Console.WriteLine(result); . Це підтверджує, що дані тепер обґрунтовані.

Під час порівняльного тестування потрібно оцінити реалістичне навантаження. Але хто б міг подумати, що невинний підрозділ може зіпсувати ваш орієнтир ?!

Спробуйте просто підсумувати цифри, щоб отримати кращий орієнтир.

Ділення та модуль завжди дуже повільні. Якщо ви модифікуєте BCLDictionary код щоб просто не використовувати оператор modulo для обчислення показників сегмента, вимірювана продуктивність покращується. Ось такий повільний поділ.

Ось 32-розрядний код:

введіть тут опис зображення

64-розрядний код (однакова структура, швидкий розподіл):

введіть тут опис зображення

Це не векторизується, незважаючи на те, що використовуються інструкції SSE.


11
"Хто б міг подумати, що невинний підрозділ може зіпсувати ваш орієнтир?" Я відразу ж, як тільки побачив поділ у внутрішній петлі, особливо. як частина ланцюга залежностей. Відділ лише безневинний , коли цілочисельне ділення це на ступеня 2. З agner.org/optimize таблиць insn: Nehalem fdivє 7-27 циклів очікування (і тим же зворотним пропускна здатність ). divsdстановить 7-22 цикли. addsdпри затримці 3c, пропускна здатність 1 / c. Відділ є єдиним неконвеєрним модулем виконання в процесорах Intel / AMD. C # JIT не векторизує цикл для x86-64 (with divPd).
Пітер Кордес,

1
Крім того, чи нормально для 32b C # не використовувати математику SSE? Чи не можна використовувати функції поточної машинної частини точки JIT? Тож на Haswell та пізніших версіях він може автоматично векторизувати цілі цикли з 256b AVX2, а не просто SSE. Щоб отримати векторизацію циклів FP, я думаю, вам доведеться писати їх з такими матеріалами, як 4 акумулятори паралельно, оскільки математика FP не асоціативна. Але в будь-якому випадку, використання SSE в 32-бітному режимі швидше, оскільки у вас менше вказівок для виконання тієї самої роботи зі скалярами, коли вам не потрібно жонглювати стеком F87 x87.
Пітер Кордес,

4
У будь-якому випадку, div дуже повільний, але 10B x87 fdiv не набагато повільніший, ніж 8B SSE2, тому це не пояснює різницю між x86 та x86-64. Що могло б пояснити це виключенням FPU або уповільненням з низькими показниками / нескінченностями. Управлінське слово x87 FPU є окремим від реєстру управління округленням / виключенням SSE ( MXCSR). По-різному поводження з денормалями або NaNя міг би, на мою думку, пояснити коефіцієнт 26 перф. C # може встановити денормальні значення-дорівнює нулю в MXCSR.
Пітер Кордес,

2
@Trauer та usr: Я щойно помітив, що valueList[i] = i, починаючи з i=0, так робить перша ітерація циклу 0.0 / 0.0. Отже, кожна операція у всьому вашому тесті виконується за допомогою NaNs. Цей розділ виглядає все менше і менше невинним! Я не фахівець з продуктивності з NaNs, або різниці між x87 та SSE для цього, але я думаю, що це пояснює різницю в 26 разів. Б'юся об заклад, ваші результати будуть набагато ближчими між 32 і 64 бітами, якщо ви ініціалізуєте valueList[i] = i+1.
Пітер Кордес,

1
Що стосується змивання до нуля, я не надто захоплююсь цим з 64-бітним подвійним, але коли 80-бітне розширене та 64-бітове подвійне використовуються разом, ситуації, коли 80-бітове значення може перевищити, а потім отримати достатньо масштаб отримати значення, яке можна було б представити як 64-біт, doubleбуло б досить рідко. Однією з основних схем використання для 80-розрядного типу було дозволити підсумовувати кілька чисел без необхідності щільно округляти результати до самого кінця. За такою схемою переливи просто не є проблемою.
supercat

31

valueList[i] = i, починаючи з i=0, так робить перша ітерація циклу 0.0 / 0.0. Отже, кожна операція у всьому вашому тесті виконується за допомогою NaNs.

Як показав @usr при розбиранні даних , 32-бітна версія використовувала x87 з плаваючою комою, тоді як 64 біт використовувала SSE з плаваючою комою.

Я не фахівець з продуктивності з NaNs, або різниці між x87 та SSE для цього, але я думаю, що це пояснює різницю в 26 разів. Б'юся об заклад, ваші результати будуть набагато ближчими між 32 і 64 бітами, якщо ви ініціалізуєте valueList[i] = i+1. (оновлення: usr підтвердив, що це зробило 32 та 64-бітну продуктивність досить близькою.)

Поділ дуже повільний у порівнянні з іншими операціями. Дивіться мої коментарі до відповіді @ usr. Також на веб-сайті http://agner.org/optimize/ ви знайдете багато чудових матеріалів про обладнання та оптимізацію asm та C / C ++, деякі з них стосуються C #. Він має таблиці інструкцій із затримкою та пропускною здатністю для більшості інструкцій для всіх останніх процесорів x86.

Однак 10B x87 fdivне набагато повільніше, ніж подвійна точність SSE2 8B divsd, для нормальних значень. IDK про різницю в перформансі з NaN, нескінченностями або деннормалами.

Однак у них різний контроль над тим, що відбувається з NaN та іншими винятками FPU. X87 управління ФПОМ слово відокремлено від регістра управління округленням / винятків SSE (MXCSR). Якщо x87 отримує виняток центрального процесора для кожного підрозділу, а SSE - ні, це легко пояснити коефіцієнт 26. Або, можливо, існує просто така різниця в продуктивності при обробці NaN. Апаратне забезпечення не оптимізовано для переробки NaNпісля NaN.

IDK, якщо тут увійдуть в дію засоби управління SSE, щоб уникнути уповільнення з використанням ненормальних значень, оскільки я вважаю, що resultце буде NaNпостійно. IDK, якщо C # встановлює прапор денормальних значень - нуль у MXCSR, або прапор змиву до нуля (який пише нулі в першу чергу, замість того, щоб розглядати денормальні значення як нуль при зчитуванні).

Я знайшов статтю Intel про елементи керування плаваючою точкою SSE, протиставляючи її слову керування FPU x87. NaNОднак тут мало що сказати . Це закінчується цим:

Висновок

Щоб уникнути проблем із серіалізацією та продуктивністю через денормальні та недолікові номери, використовуйте інструкції SSE та SSE2, щоб встановити в апаратному режимі режими Flush-to-Zero та Denormals-Are-Zero, щоб забезпечити найвищу продуктивність для програм із плаваючою крапкою.

IDK, якщо це допомагає комусь із діленням на нуль.

для проти foreach

Може бути цікаво протестувати тіло циклу, яке обмежено пропускною здатністю, а не просто єдиним ланцюжком залежностей, що несеться із циклом. В даний час вся робота залежить від попередніх результатів; процесору немає нічого робити паралельно (крім обмеження перевіряйте наступне навантаження масиву, поки працює ланцюжок mul / div).

Ви можете побачити більшу різницю між методами, якщо "реальна робота" зайняла більшу кількість ресурсів для виконання процесорів. Крім того, на Intel до Sandybridge існує велика різниця між тим, як вставляти петлю в буфер циклу 28uop, чи ні. Ви отримуєте інструкції щодо декодування вузьких місць, якщо ні, особливо. коли середня тривалість інструкції довша (що трапляється з SSE). Інструкції, які декодують більше, ніж одне загальне, також обмежать пропускну здатність декодера, за винятком випадків, коли вони надходять у шаблоні, який приємний для декодерів (наприклад, 2-1-1). Отже, цикл з більшою кількістю вказівок накладних витрат на цикл може зробити різницю між розміщенням циклу в кеші uop із 28 записів чи ні, що є великою справою для Nehalem, а іноді корисно для Sandybridge та пізніших версій.


У мене ніколи не було випадків, щоб я спостерігав різницю в продуктивності, засновану на тому, чи були NaN в моєму потоці даних, але наявність денормалізованих чисел може зробити величезну різницю в продуктивності. У цьому прикладі це не так, але про це слід пам’ятати.
Jason R,

@JasonR: Це лише тому, NaNщо вони насправді рідкісні на практиці? Я залишив у всьому, що стосується денормалів, і посилання на речі Intel, здебільшого на користь читачів, не тому, що думав, що це справді сильно вплине на цей конкретний випадок.
Пітер Кордес,

У більшості застосувань вони зустрічаються рідко. Однак при розробці нового програмного забезпечення, яке використовує плаваючу крапку, зовсім не рідко помилки реалізації дають потоки NaN замість бажаних результатів! Це траплялося у мене багато разів, і я не згадую жодного помітного хіту продуктивності, коли спливають NaN. Я спостерігав протилежне, якщо роблю щось, що спричиняє появу дендормів; що, як правило, призводить до негативного помітного зниження продуктивності. Зауважте, що вони ґрунтуються лише на моєму анекдотичному досвіді; може статися деяке падіння продуктивності з NaN, чого я просто не помітив.
Jason R

@JasonR: IDK, можливо, NaNs не набагато повільніші з SSE. Очевидно, що вони є великою проблемою для x87. Семантика SSE FP була розроблена компанією Intel у дні PII / PIII. Ці центральні процесори мають таку ж непрацюючу техніку під капотом, що і сучасні конструкції, тому, мабуть, вони мали на увазі високу продуктивність для P6 при розробці SSE. (Так, Skylake базується на мікроархітектурі P6. Деякі речі змінилися, але він все одно декодується до uops, і планує їх до портів виконання за допомогою буфера переупорядкування.) Семантика x87 була розроблена для додаткового зовнішнього чіпа співпроцесора для впорядкований скалярний процесор.
Пітер Кордес

@PeterCordes Викликати Skylake мікросхемою на базі P6 - занадто далеко. 1) FPU (майже) повністю перероблений під час епохи Піщаного мосту, тому старий FPU P6 в основному зник до сьогодні; 2) декодування x86 to uop мало критичну модифікацію під час ери Core2: в той час як попередні проекти декодували обчислення та інструкції пам'яті як окремі uops, мікросхема Core2 + має uops, що складаються з обчислювальної інструкції та оператора пам'яті. Це призвело до значного збільшення продуктивності та енергоефективності за рахунок більш складної конструкції та потенційно нижчої пікової частоти.
shodanshok

1

Ми спостерігаємо, що 99,9% усіх операцій з плаваючою комою будуть залучати NaN, що є принаймні надзвичайно незвичним (першим знайдений Пітером Кордесом). У нас є ще один експеримент від USR, який виявив, що видалення інструкцій на поділ майже повністю знижує різницю в часі.

Справа в тому, що NaN генеруються лише тому, що найперший поділ обчислює 0,0 / 0,0, що дає початковий NaN. Якщо ділення не виконуються, результат завжди буде 0,0, і ми завжди будемо обчислювати 0,0 * temp -> 0,0, 0,0 + temp -> temp, temp - temp = 0,0. Отже, вилучення дивізії призвело не лише до видалення дивізій, але й до видалення NaN. Я би очікував, що NaN насправді є проблемою, і що одна реалізація обробляє NaN дуже повільно, тоді як інша не має проблем.

Варто запустити цикл при i = 1 і виміряти знову. Результат чотирьох операцій * temp, + temp, / temp, - temp ефективно додає (1 - temp), щоб у нас не було ніяких незвичних чисел (0, нескінченність, NaN) для більшості операцій.

Єдина проблема може полягати в тому, що поділ завжди дає цілий результат, а деякі реалізації поділу мають ярлики, коли правильний результат не використовує багато бітів. Наприклад, ділення 310,0 / 31,0 дає 10,0 як перші чотири біти з залишком 0,0, і деякі реалізації можуть припинити оцінювати решту 50 або більше бітів, тоді як інші не можуть. Якщо є суттєва різниця, тоді запуск циклу з результатом = 1,0 / 3,0 матиме різницю.


-2

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

Архітектура процесора:

Архітектура процесора Intel була суто 64-бітною. Для того, щоб виконати 32-бітний код, 32-бітні інструкції перед виконанням потрібно було перетворити (всередині ЦП) на 64-бітні.

Архітектура процесора AMD мала побудувати 64-бітну вершину над їх 32-бітною архітектурою; тобто, по суті, це була 32-бітна архітектура з 64-бітними розширеннями - не було процесу перетворення коду.

Очевидно, це було кілька років тому, тому я не маю уявлення про те, як / як змінилася технологія, але по суті, ви очікували б, що 64-бітний код буде працювати ефективніше на 64-бітній машині, оскільки центральний процесор може працювати з подвоєною кількістю біт за інструкцію.

.NET JIT

Стверджується, що .NET (та інші керовані мови, такі як Java) здатні перевершувати такі мови, як C ++, завдяки тому, що компілятор JIT здатний оптимізувати ваш код відповідно до архітектури вашого процесора. У цьому відношенні ви можете виявити, що компілятор JIT використовує щось у 64-бітній архітектурі, що, можливо, було недоступним або вимагало обхідного шляху при виконанні в 32-бітному режимі.

Примітка:

Замість того, щоб використовувати DoubleWrapper, чи роздумували ви над використанням Nullable<double>або скороченим синтаксисом: double?- Мені було б цікаво побачити, чи це вплине на ваші тести.

Примітка 2. Деякі люди, схоже, змішують мої коментарі щодо 64-бітної архітектури з IA-64. Тільки для уточнення, у моїй відповіді 64-бітний посилається на x86-64, а 32-бітний - на x86-32. Тут нічого не згадується про IA-64!


4
Добре, так чому це в 26 разів швидше? Не можу знайти це у відповіді.
usr

2
Я здогадуюсь, що це різниця в тремтінні, але не більше, ніж вгадування.
Джон Ханна,

2
@seriesOne: Я думаю, MSalters намагається сказати, що ви змішуєте IA-64 з x86-64. (Intel також використовує IA-32e для x86-64, у своїх посібниках). Процесори для настільних ПК усіх мають x86-64. Itanic затонув кілька років тому, і, думаю, в основному він використовувався на серверах, а не на робочих станціях. Core2 (перший процесор сімейства P6, що підтримує довгий режим x86-64) насправді має деякі обмеження в 64-бітному режимі. наприклад, uop macro-fusion працює лише в 32-бітному режимі. Intel і AMD зробили те саме: розширили свої 32-бітні конструкції до 64-бітних.
Пітер Кордес,

1
@PeterCordes, де я згадав про IA-64? Мені відомо, що процесори Itanium були абсолютно іншим набором конструкцій та інструкцій; ранні моделі, позначені як EPIC або явно паралельні обчислення інструкцій. Я думаю, MSalters поєднує 64-бітну версію та IA-64. Моя відповідь справедлива для архітектури x86-64 - там нічого не згадувалося про сімейство процесорів Itanium
Метью Лейтон,

2
@ series0ne: Гаразд, тоді ваш абзац про процесори Intel, що є "суто 64-бітними", є повною нісенітницею. Я припускав, що ви думаєте про IA-64, бо тоді ви б не помилились повністю. Ніколи не було зайвого кроку перекладу для запуску 32-бітного коду. Декодери x86-> uop просто мають два схожі режими: x86 та x86-64. Intel побудував 64-бітний P4 поверх P4. 64-бітний Core2 оснащений багатьма іншими архітектурними вдосконаленнями в порівнянні з Core і Pentium M, але такі речі, як макрофьюжн, що працює лише в 32-бітному режимі, показують, що 64-бітна система була закріплена болтами. (досить рано в процесі проектування, але все ж.)
Пітер Кордес,
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.