Спробуйте пришвидшити мій код?


1503

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

static void Main(string[] args)
{
    Thread.CurrentThread.Priority = ThreadPriority.Highest;
    Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.RealTime;

    long start = 0, stop = 0, elapsed = 0;
    double avg = 0.0;

    long temp = Fibo(1);

    for (int i = 1; i < 100000000; i++)
    {
        start = Stopwatch.GetTimestamp();
        temp = Fibo(100);
        stop = Stopwatch.GetTimestamp();

        elapsed = stop - start;
        avg = avg + ((double)elapsed - avg) / i;
    }

    Console.WriteLine("Elapsed: " + avg);
    Console.ReadKey();
}

static long Fibo(int n)
{
    long n1 = 0, n2 = 1, fibo = 0;
    n++;

    for (int i = 1; i < n; i++)
    {
        n1 = n2;
        n2 = fibo;
        fibo = n1 + n2;
    }

    return fibo;
}

На моєму комп'ютері це послідовно виводить значення близько 0,96 ..

Коли я загортаю цикл for Fibo всередину Fibo () з блоком спробу-catch, як це:

static long Fibo(int n)
{
    long n1 = 0, n2 = 1, fibo = 0;
    n++;

    try
    {
        for (int i = 1; i < n; i++)
        {
            n1 = n2;
            n2 = fibo;
            fibo = n1 + n2;
        }
    }
    catch {}

    return fibo;
}

Тепер він послідовно друкує 0,69 ... - він фактично працює швидше! Але чому?

Примітка: Я скомпілював це за допомогою конфігурації Release і безпосередньо запустив файл EXE (за межами Visual Studio).

EDIT: Прекрасний аналіз Джона Скета показує, що спроба улову якимось чином змушує x86 CLR використовувати регістри процесора більш сприятливим чином у цьому конкретному випадку (і я думаю, ми ще не зрозуміли чому). Я підтвердив висновок Джона, що в CL64 x64 немає такої різниці, і що він був швидшим, ніж CLR x86. Я також протестував, використовуючи intтипи всередині методу Fibo замість longтипів, і тоді CL86 x86 був настільки ж швидким, як і CL64 x64.


ОНОВЛЕННЯ: Схоже, цю проблему вирішив Рослін. Та сама машина, та сама версія CLR - при компілюванні з VS 2013 ця проблема залишається такою, як описана вище, але проблема зникає, коли компілюється з VS 2015.


111
@Lloyd він намагається отримати відповідь на своє запитання "це насправді працює швидше! Але чому?"
Андреас Нідермайр

137
Отже, зараз "Проковтування винятків" перейшло від поганої практики до гарної оптимізації ефективності: P
Luciano

2
Це в неперевіреному або перевіреному арифметичному контексті?
Випадково832

7
@ taras.roshko: Хоча я не хочу робити Еріку недобре, але це насправді не питання C # - це питання компілятора JIT. Кінцева складність полягає в тому, щоб розробити, чому x86 JIT не використовує стільки регістрів без спроби / лову, як це відбувається з блоком спробу / лову.
Джон Скіт

63
Солодке, тож якщо ми вкладемо ці спробування, ми можемо піти ще швидше, чи не так?
Чак Пінкерт

Відповіді:


1053

Один із інженерів компанії Roslyn, який спеціалізується на розумінні оптимізації використання стека, поглянув на це і повідомив мені, що, мабуть, існує проблема у взаємодії між способом компілятора C #, що генерує локальні сховища змінних, і тим, як реєструє компілятор JIT планування у відповідному коді x86. Результат - неоптимальне генерування коду для вантажів та магазинів місцевих жителів.

Чомусь незрозумілим для всіх нас, шлях утворення проблемного коду уникається, коли JITter знає, що блок знаходиться у захищеній від спроб області.

Це досить дивно. Ми зв’яжемось із командою JITter і побачимо, чи можемо ми ввести помилку, щоб вони могли виправити це.

Крім того, ми працюємо над вдосконаленням алгоритмів компіляторів C # і VB для Roslyn, щоб визначити, коли місцеві жителі можуть бути зроблені "ефемерними" - тобто просто натискати і вискакувати на стек, а не виділяти певне місце на стеку для тривалість активації. Ми віримо, що JITter зможе виконати кращу роботу щодо розподілу реєстру, і що ні, якщо ми дамо йому кращі підказки про те, коли місцеві жителі можуть бути раніше "мертвими".

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


8
Мені завжди було цікаво, чому компілятор C # генерує стільки сторонніх місцевих жителів. Наприклад, нові вирази ініціалізації масиву завжди генерують локальну, але ніколи не потрібна для створення локальної. Якщо він дозволяє JITter виробляти помітно більш ефективний код, можливо, компілятор C # повинен бути трохи уважнішим щодо генерування непотрібних місцевих жителів ...
Timwi

33
@Timwi: Абсолютно. У неоптимізованому коді компілятор створює непотрібні місцеві жителі з великою відмовою, тому що вони спрощують налагодження. В оптимізованому коді непотрібні тимчасові файли повинні бути видалені, якщо це можливо. На жаль, у нас було багато помилок протягом багатьох років, коли ми випадково деоптимізували оптимізатор тимчасового усунення. Вищезгаданий інженер повністю робив з нуля весь цей код для Roslyn, і в результаті ми повинні значно покращити оптимізовану поведінку в генераторі коду Roslyn.
Ерік Ліпперт

24
Чи було в цьому питанні якийсь рух?
Роберт Харві

10
Схоже, Рослін це виправив.
Eren Ersönmez

56
Ви пропустили свою можливість назвати це "помилка JITter".
mbomb007

734

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

var stopwatch = Stopwatch.StartNew();
for (int i = 1; i < 100000000; i++)
{
    Fibo(100);
}
stopwatch.Stop();
Console.WriteLine("Elapsed time: {0}", stopwatch.Elapsed);

Таким чином, вам не до душі крихітні таймінги, арифметика з плаваючою комою та накопичена помилка.

Зробивши цю зміну, подивіться, чи все-таки повільніша версія "не лову", ніж версія "лову".

EDIT: Гаразд, я сам спробував це - і я бачу той же результат. Дуже дивно. Мені було цікаво, чи відключення спробу / лову відключило якийсь поганий вклад, але використовуючи[MethodImpl(MethodImplOptions.NoInlining)] натомість не допомогло ...

В основному вам потрібно буде переглянути оптимізований код JITted під cordbg, я підозрюю ...

EDIT: Ще кілька біт інформації:

  • Поставивши спробувати / наздогнати лише те n++; поставити лінію, все одно покращується продуктивність, але не настільки, як розміщення її по всьому блоку
  • Якщо ви ловите конкретний виняток (ArgumentException у моїх тестах), це все одно швидко
  • Якщо ви надрукуєте виняток у блоці вилову, це все ще швидко
  • Якщо ви повторно скидаєте виняток у блок ловлі, це знову повільно
  • Якщо ви використовуєте остаточно блок замість блоку лову, він знову повільний
  • Якщо ви використовуєте остаточний блок , а також блок лову, це швидко

Дивно ...

EDIT: Гаразд, у нас є розбирання ...

Для цього використовується компілятор C # 2 та .NET 2 (32-розрядний) CLR, розбирання з mdbg (оскільки у мене на моїй машині не існує cordbg). Я все ще бачу ті ж ефекти, навіть під налагоджувачем. Швидка версія використовує tryблок навколо всього між деклараціями змінної та оператором return, за допомогою лише catch{}обробника. Очевидно, що повільна версія однакова, за винятком спроб / улов. Код виклику (тобто Main) є однаковим в обох випадках і має однакове представлення складання (тому це не є вкладеною проблемою).

Розібраний код для швидкої версії:

 [0000] push        ebp
 [0001] mov         ebp,esp
 [0003] push        edi
 [0004] push        esi
 [0005] push        ebx
 [0006] sub         esp,1Ch
 [0009] xor         eax,eax
 [000b] mov         dword ptr [ebp-20h],eax
 [000e] mov         dword ptr [ebp-1Ch],eax
 [0011] mov         dword ptr [ebp-18h],eax
 [0014] mov         dword ptr [ebp-14h],eax
 [0017] xor         eax,eax
 [0019] mov         dword ptr [ebp-18h],eax
*[001c] mov         esi,1
 [0021] xor         edi,edi
 [0023] mov         dword ptr [ebp-28h],1
 [002a] mov         dword ptr [ebp-24h],0
 [0031] inc         ecx
 [0032] mov         ebx,2
 [0037] cmp         ecx,2
 [003a] jle         00000024
 [003c] mov         eax,esi
 [003e] mov         edx,edi
 [0040] mov         esi,dword ptr [ebp-28h]
 [0043] mov         edi,dword ptr [ebp-24h]
 [0046] add         eax,dword ptr [ebp-28h]
 [0049] adc         edx,dword ptr [ebp-24h]
 [004c] mov         dword ptr [ebp-28h],eax
 [004f] mov         dword ptr [ebp-24h],edx
 [0052] inc         ebx
 [0053] cmp         ebx,ecx
 [0055] jl          FFFFFFE7
 [0057] jmp         00000007
 [0059] call        64571ACB
 [005e] mov         eax,dword ptr [ebp-28h]
 [0061] mov         edx,dword ptr [ebp-24h]
 [0064] lea         esp,[ebp-0Ch]
 [0067] pop         ebx
 [0068] pop         esi
 [0069] pop         edi
 [006a] pop         ebp
 [006b] ret

Розібраний код для повільної версії:

 [0000] push        ebp
 [0001] mov         ebp,esp
 [0003] push        esi
 [0004] sub         esp,18h
*[0007] mov         dword ptr [ebp-14h],1
 [000e] mov         dword ptr [ebp-10h],0
 [0015] mov         dword ptr [ebp-1Ch],1
 [001c] mov         dword ptr [ebp-18h],0
 [0023] inc         ecx
 [0024] mov         esi,2
 [0029] cmp         ecx,2
 [002c] jle         00000031
 [002e] mov         eax,dword ptr [ebp-14h]
 [0031] mov         edx,dword ptr [ebp-10h]
 [0034] mov         dword ptr [ebp-0Ch],eax
 [0037] mov         dword ptr [ebp-8],edx
 [003a] mov         eax,dword ptr [ebp-1Ch]
 [003d] mov         edx,dword ptr [ebp-18h]
 [0040] mov         dword ptr [ebp-14h],eax
 [0043] mov         dword ptr [ebp-10h],edx
 [0046] mov         eax,dword ptr [ebp-0Ch]
 [0049] mov         edx,dword ptr [ebp-8]
 [004c] add         eax,dword ptr [ebp-1Ch]
 [004f] adc         edx,dword ptr [ebp-18h]
 [0052] mov         dword ptr [ebp-1Ch],eax
 [0055] mov         dword ptr [ebp-18h],edx
 [0058] inc         esi
 [0059] cmp         esi,ecx
 [005b] jl          FFFFFFD3
 [005d] mov         eax,dword ptr [ebp-1Ch]
 [0060] mov         edx,dword ptr [ebp-18h]
 [0063] lea         esp,[ebp-4]
 [0066] pop         esi
 [0067] pop         ebp
 [0068] ret

У кожному випадку * показує, куди налагоджувач увійшов у простий "крок".

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

Можливо, блок "try / catch" змушує більше реєстрів зберігатись і відновлюватися, тому JIT використовує і ці для циклу ... що відбувається для покращення загальної продуктивності. Не ясно, чи є розумним рішенням JIT не використовувати стільки регістрів у "звичайному" коді.

EDIT: Просто спробував це на моїй машині x64. CL64 x64 набагато швидше (приблизно в 3-4 рази швидше), ніж CL86 x86 у цьому коді, і під x64 блок try / catch не помітно помітний.


4
@GordonSimpson, але у випадку, коли спіймається лише конкретний виняток, тоді всі інші винятки не потраплять, тож все, що накладні кошти було задіяно у вашій гіпотезі про непробований, все одно знадобиться.
Джон Ханна

45
Схоже на різницю в розподілі реєстру. Швидку версію вдається використовувати esi,ediдля одного з довгих замість стека. Він використовується ebxяк лічильник, де використовується повільна версія esi.
Джеффрі Сакс

13
@JeffreySax: Справа не лише в тому, які регістри використовуються, а скільки. Повільна версія використовує більше місця у стеку, торкаючись меншої кількості регістрів. Я не маю поняття, чому ...
Джон Скіт

2
Як розглядаються кадри винятків CLR з точки зору регістрів та стеків? Чи може один із налаштувань звільнити реєстр для використання якось?
Випадково832

4
IIRC x64 має більше регістрів, ніж x86. Ви побачили прискорене швидкість, що відповідає додатковій спробі / лову додатковому використанню реєстрації під x86
День підлягає Firelight

116

Розбирання Джона показують, що різниця між двома версіями полягає в тому, що швидка версія використовує пару регістрів ( esi,edi) для зберігання однієї з локальних змінних, де повільна версія відсутня.

Компілятор JIT робить різні припущення щодо використання регістру для коду, який містить блок пробного коду проти коду, який не відповідає. Це змушує її робити різні варіанти розподілу реєстру. У цьому випадку це надає перевагу коду за допомогою блоку спробу. Різний код може призвести до протилежного ефекту, тому я б не вважав це загальноприйнятою технікою прискорення швидкості.

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

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

interface IIndexed { int this[int index] { get; set; } }
struct StructArray : IIndexed { 
    public int[] Array;
    public int this[int index] {
        get { return Array[index]; }
        set { Array[index] = value; }
    }
}

static int Generic<T>(int length, T a, T b) where T : IIndexed {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}
static int Specialized(int length, StructArray a, StructArray b) {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}

Одне - це родова версія другого. Заміна загального типу StructArrayна метод зробить методи однаковими. Оскільки StructArrayце тип значення, він отримує власну складену версію загального методу. Однак фактичний час роботи значно довший, ніж у спеціалізованого методу, але лише для x86. Для x64 терміни майже однакові. В інших випадках я також спостерігав відмінності для x64.


6
З урахуванням цього, чи можете ви примушувати різні варіанти розподілу реєстру, не використовуючи Try / Catch? Або як тест на цю гіпотезу, або як загальна спроба налаштувати швидкість?
WernerCD

1
Існує ряд причин, через які цей конкретний випадок може бути різним. Можливо, це спроба улову. Можливо, це той факт, що змінні повторно використовуються у внутрішній області. Незалежно від конкретної причини, це детальна інформація про реалізацію, яку ви не можете розраховувати на збереження, навіть якщо в тій же програмі викликається той самий код.
Джефрі Сакс

4
@WernerCD Я б сказав, що факт, що у C і C ++ є ключове слово, яке дозволяє припустити, що (A) ігнорується багатьма сучасними компіляторами, і (B) було вирішено не ставити в C #, говорить про те, що це не те, що ми ' Побачимо більш прямим способом.
Джон Ханна

2
@WernerCD - Тільки якщо ви самі пишете збори
OrangeDog

72

Це виглядає як випадок, коли вкладка пішла погано. На ядрі x86 у джиттера є регістр ebx, edx, esi та edi, доступний для зберігання локальних змінних загального призначення. Регістр ECX стає доступним методом статичного, він не повинен зберігати цей . Реєстр eax часто потрібен для розрахунків. Але це 32-бітні регістри, для змінних типів довгих він повинен використовувати пару регістрів. Які є edx: eax для обчислень та edi: ebx для зберігання.

Що саме вирізняється в розборці для повільної версії, не використовуються ні edi, ні ebx.

Коли джиттер не може знайти достатню кількість регістрів для зберігання локальних змінних, він повинен генерувати код для завантаження та зберігання їх із кадру стека. Це уповільнює код, він перешкоджає оптимізації процесора під назвою "перейменування реєстру", внутрішній трюк оптимізації ядра процесора, який використовує кілька копій реєстру та дозволяє виконувати надскалярне виконання. Що дозволяє виконувати кілька інструкцій одночасно, навіть якщо вони використовують один і той же реєстр. Недостатня кількість регістрів є поширеною проблемою на ядрах x86, вирішених у x64, у яких є 8 додаткових регістрів (r9 до r15).

Джиттер зробить все можливе, щоб застосувати іншу оптимізацію генерації коду, він спробує вбудувати ваш метод Fibo (). Іншими словами, не здійснюйте виклик методу, а генеруйте код для методу, вбудованого в метод Main (). Досить важлива оптимізація, яка, наприклад, робить властивості класу C # безкоштовно, надаючи їм perf поля. Це дозволяє уникнути накладних витрат на виклик методу та встановлення його кадру стека, економить пару наносекунд.

Існує кілька правил, які точно визначають, коли метод може бути накреслений. Вони не є точно задокументованими, але про них згадували у публікаціях блогу. Одне правило - це не відбудеться, коли тіло методу занадто велике. Уражаючи виграш від вбудовування, він генерує занадто багато коду, який не так добре вписується в кеш-інструкцію L1. Ще одне жорстке правило, яке застосовується тут, полягає в тому, що метод не буде накреслений, коли він містить операцію спробувати. Передумовою цього є деталізація винятків про винятки, вони повертаються до вбудованої підтримки Windows для SEH (Structure Exception Handling), що базується на стекових кадрах.

Про одну поведінку алгоритму розподілу реєстру в тремтінні можна зробити висновок про гру з цим кодом. Здається, відомо про те, коли тремтіння намагається вкласти метод. Одне правило, як видається, використовує те, що тільки пара реєстрації edx: eax може використовуватися для вбудованого коду, який має локальні змінні типу long. Але не edi: ebx. Без сумніву, оскільки це було б занадто згубно для генерації коду для методу виклику, і edi, і ebx є важливими регістрами зберігання.

Таким чином, ви отримуєте швидку версію, тому що тремтіння знає наперед, що тіло методу містить операції try / catch. Він знає, що його ніколи не можна накреслити, тому легко використовує edi: ebx для зберігання для довгої змінної. Ви отримали повільну версію, тому що тремтіння не знало наперед, що вкладка не буде працювати. Це з'ясувалося лише після генерації коду для тіла методу.

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

Це сповільнення не відбувається на x64, оскільки для одного він має ще 8 регістрів. Для іншого, оскільки він може довго зберігати лише в одному реєстрі (наприклад, rax). І уповільнення не відбувається, коли ви використовуєте int замість довгого, оскільки тремтіння має набагато більшу гнучкість у виборі регістрів.


21

Я б сказав це як коментар, оскільки я справді не впевнений, що це, мабуть, так, але, як я пам'ятаю, це не намагається / крім заяви не передбачає зміни способу механізму вивезення сміття компілятор працює, тим, що очищає виділення об'єктів пам'яті рекурсивно з стека. У цьому випадку може бути не очищено об'єкт, або цикл for може створити закриття, яке механізм вивезення сміття визнає достатнім для застосування іншого способу збирання. Напевно, ні, але я вважав, що це варто згадати, оскільки я не бачив, щоб це обговорювалося більше ніде.

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