Попробуй ускорить мой код?

1505

Я написал некоторый код для тестирования воздействия try-catch, но увидел некоторые неожиданные результаты.

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 () блоком try-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).

РЕДАКТИРОВАТЬ: превосходный анализ Джона Скита показывает, что try-catch как-то заставляет CLR x86 использовать регистры ЦП более выгодным способом в этом конкретном случае (и я думаю, что мы еще не поняли почему). Я подтвердил вывод Джона о том, что x64 CLR не имеет этой разницы и что она была быстрее, чем x86 CLR. Я также протестировал использование intтипов внутри метода Fibo вместо longтипов, и затем x86 CLR был таким же быстрым, как и x64 CLR.


ОБНОВЛЕНИЕ: Похоже, эта проблема была исправлена ​​Рослин. Та же машина, та же версия CLR - проблема остается такой же, как описано выше при компиляции с VS 2013, но проблема исчезает при компиляции с VS 2015.

Эрен Эрсонмез
источник
111
@ Ллойд, он пытается получить ответ на свой вопрос: «На самом деле он работает быстрее! Но почему?»
Андреас Нидермаир
137
Итак, теперь «глотание исключений» перешло от плохой практики к хорошей оптимизации производительности: P
Лучано
2
Это в непроверенном или проверенном арифметическом контексте?
Random832
7
@ taras.roshko: Хотя я не хочу оказывать Эрику медвежью услугу, на самом деле это не вопрос C #, а вопрос JIT-компилятора. Конечная трудность разработка , почему x86 JIT не использует так много регистров без Try / улова , как это делает с блоком Try / улова.
Джон Скит
63
Сладкий, так что, если мы вложим эти пробные уловы, мы сможем пойти еще быстрее, верно?
Чак Пинкерт

Ответы:

1054

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

По какой-то непонятной для всех нас причине проблематичный путь генерации кода избегается, когда JITter знает, что блок находится в защищенной от попыток области.

Это довольно странно. Мы свяжемся с командой JITter и посмотрим, сможем ли мы ввести ошибку, чтобы они могли это исправить.

Кроме того, мы работаем над улучшением для Roslyn алгоритмов компиляторов C # и VB, чтобы определить, когда локальные объекты можно сделать «эфемерными», то есть просто помещать их в стек, а не выделять определенное место в стеке для продолжительность активации. Мы полагаем, что JITter сможет лучше распределять регистры, и вообще, если мы дадим ему подсказки о том, когда местные жители могут быть «мертвыми» раньше.

Спасибо за то, что обратили на это наше внимание, и приносим извинения за странное поведение.

Эрик Липперт
источник
8
Мне всегда было интересно, почему компилятор C # генерирует так много посторонних локальных объектов. Например, новые выражения инициализации массива всегда генерируют локальный, но никогда не нужны для генерации локального. Если это позволяет JITter генерировать заметно более производительный код, возможно, компилятору C # следует быть немного более осторожным в создании ненужных
локальных элементов
33
@ Тимви: Абсолютно. В неоптимизированном коде компилятор создает ненужные локальные объекты, поскольку они упрощают отладку. В оптимизированном коде ненужные временные промежутки должны быть удалены, если это возможно. К сожалению, за эти годы у нас было много ошибок, когда мы случайно де-оптимизировали оптимизатор временного исключения. Вышеупомянутый инженер полностью заново выполняет весь этот код для Roslyn, и в результате мы должны значительно улучшить оптимизированное поведение в генераторе Roslyn.
Эрик Липперт
24
Было ли когда-нибудь движение по этому вопросу?
Роберт Харви
10
Похоже, Рослин это исправила.
Эрен Эрсонмез
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);

Таким образом, вы не находитесь во власти крошечных таймингов, арифметики с плавающей запятой и накопленной ошибки.

Сделав это изменение, посмотрите, не является ли версия без улова более медленной, чем версия с уловом.

РЕДАКТИРОВАТЬ: Хорошо, я попробовал это сам - и я вижу тот же результат. Очень странно. Я задавался вопросом, отключает ли try / catch какое-то плохое встраивание, но используя[MethodImpl(MethodImplOptions.NoInlining)] вместо этого не помогло ...

В принципе, вам нужно взглянуть на оптимизированный код JITted в cordbg, я подозреваю ...

РЕДАКТИРОВАТЬ: еще несколько бит информации:

  • Размещение try / catch вокруг только n++;строки по-прежнему улучшает производительность, но не настолько, как размещение всего блока
  • Если вы поймете конкретное исключение ( ArgumentExceptionв моих тестах), это все еще быстро
  • Если вы печатаете исключение в блоке catch, оно все равно быстро
  • Если вы перебрасываете исключение в блоке catch, оно снова медленно
  • Если вы используете блок finally вместо блока catch, он снова замедляется
  • Если вы используете блок finally, а также блок catch, это быстро

Weird ...

РЕДАКТИРОВАТЬ: Хорошо, у нас есть разборка ...

Для этого используется компилятор C # 2 и .NET 2 (32-битная) CLR, дизассемблируется с помощью mdbg (поскольку на моей машине нет cordbg). Я по-прежнему вижу те же эффекты производительности, даже под отладчиком. Быстрая версия использует tryблок вокруг всего между объявлениями переменных и оператором возврата, используя только catch{}обработчик. Очевидно, что медленная версия такая же, за исключением без try / 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

В каждом случае * показывается, где отладчик вводится простым «пошаговым вводом».

РЕДАКТИРОВАТЬ: Хорошо, теперь я просмотрел код, и я думаю, что я вижу, как работает каждая версия ... и я считаю, что более медленная версия медленнее, потому что она использует меньше регистров и больше места в стеке. Для небольших значений nэто возможно быстрее, но когда цикл занимает большую часть времени, он медленнее.

Возможно, блок try / catch заставляет сохранять и восстанавливать больше регистров, поэтому JIT использует их и для цикла ... что в целом повышает производительность. Не ясно, является ли разумным решение для JIT не использовать столько регистров в «нормальном» коде.

РЕДАКТИРОВАТЬ: Только что попробовал это на моей машине x64. CLR x64 намного быстрее (примерно в 3-4 раза быстрее) CLR x86 в этом коде, а в x64 блок try / catch не имеет заметного различия.

Джон Скит
источник
4
@GordonSimpson, но в случае, когда перехвачено только определенное исключение, все остальные исключения не будут перехвачены, так что все равно потребуется дополнительная нагрузка в вашей гипотезе для отсутствия попытки.
Джон Ханна
45
Похоже, разница в распределении регистров. Быструю версию удается использовать esi,ediдля одного из длинных вместо стека. Он использует ebxв качестве счетчика, где используется медленная версия esi.
Джеффри Сакс
13
@JeffreySax: дело не только в том, какие регистры используются, сколько. Медленная версия использует больше места в стеке, затрагивая меньше регистров. Я понятия не имею, почему ...
Джон Скит
2
Как обрабатываются кадры исключений CLR с точки зрения регистров и стека? Может ли его установка как-то освободить регистр для использования?
Random832
4
IIRC x64 имеет больше регистров, чем x86. Ускорение, которое вы видели, будет соответствовать попыткам / уловкам, заставляющим использовать дополнительные регистры в x86.
Дэн возится с Firelight
116

Разборки Джона показывают, что различие между двумя версиями состоит в том, что быстрая версия использует пару registers ( esi,edi) для хранения одной из локальных переменных , в отличие от медленной версии.

JIT-компилятор делает различные предположения относительно использования регистров для кода, который содержит блок try-catch, по сравнению с кодом, который этого не делает. Это заставляет его делать разные варианты выделения регистров. В этом случае это благоприятствует коду с блоком try-catch. Разный код может привести к противоположному эффекту, поэтому я бы не считал это универсальной техникой ускорения.

В конце концов, очень трудно сказать, какой код будет работать быстрее всего. Нечто подобное распределению регистров и факторам, влияющим на него, является настолько низкоуровневыми деталями реализации, что я не вижу, как какой-то конкретный метод мог бы надежно создавать более быстрый код.

Например, рассмотрим следующие два метода. Они были адаптированы из реального примера:

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-битные регистры, для переменных типа long он должен использовать пару регистров. Это edx: eax для вычислений и edi: ebx для хранения.

Это то, что выделяется при разборке для медленной версии, ни edi, ни ebx не используются.

Когда джиттер не может найти достаточно регистров для хранения локальных переменных, он должен сгенерировать код для загрузки и сохранения их из стекового фрейма. Это замедляет код, предотвращает оптимизацию процессора под названием «переименование регистра», трюк по оптимизации внутреннего ядра процессора, который использует несколько копий регистра и позволяет выполнять суперскалярное выполнение. Что позволяет одновременно выполнять несколько инструкций, даже если они используют один и тот же регистр. Отсутствие достаточного количества регистров является распространенной проблемой на ядрах x86, решаемых в x64, который имеет 8 дополнительных регистров (от r9 до r15).

Джиттер приложит все усилия, чтобы применить другую оптимизацию генерации кода, он попытается встроить ваш метод Fibo (). Другими словами, не вызывайте метод, а сгенерируйте код для встроенного метода в методе Main (). Довольно важная оптимизация, которая, например, делает свойства класса C # бесплатными, давая им характеристики поля. Это позволяет избежать накладных расходов при вызове метода и настройке его стекового фрейма, экономя пару наносекунд.

Есть несколько правил, которые точно определяют, когда метод может быть встроен. Они не совсем задокументированы, но были упомянуты в блогах. Одно из правил заключается в том, что этого не произойдет, если тело метода слишком большое. Это сводит на нет выигрыш от встраивания, он генерирует слишком много кода, который также не помещается в кэш инструкций L1. Другое строгое правило, которое здесь применяется, заключается в том, что метод не будет встроенным, если он содержит инструкцию try / catch. Основой этого является деталь реализации исключений, они привязаны к встроенной поддержке Windows SEH (Структурная обработка исключений), которая основана на кадрах стека.

Одно поведение алгоритма распределения регистров в джиттере может быть выведено из игры с этим кодом. Похоже, известно, когда джиттер пытается встроить метод. Кажется, в одном из правил используется только пара регистров edx: eax для встроенного кода с локальными переменными типа long. Но не edi: ebx. Без сомнения, поскольку это будет слишком пагубно для генерации кода для вызывающего метода, и edi, и ebx являются важными регистрами хранения.

Таким образом, вы получаете быструю версию, потому что джиттер заранее знает, что тело метода содержит операторы try / catch. Он знает, что он никогда не может быть встроен, поэтому с готовностью использует edi: ebx для хранения длинной переменной. Вы получили медленную версию, потому что джиттер не знал заранее, что вставка не будет работать. Это выяснилось только после генерации кода для тела метода.

Недостаток в том, что он не вернулся и не сгенерировал код для метода. Что понятно, учитывая временные ограничения, в которых он должен работать.

Это замедление не происходит на x64, потому что для одного у него есть еще 8 регистров. Для другого, потому что он может хранить long только в одном регистре (например, rax). И замедление не происходит, когда вы используете int вместо long, потому что джиттер обладает гораздо большей гибкостью при выборе регистров.

Ганс Пассант
источник
21

Я бы поставил это в качестве комментария, так как я действительно не уверен, что это, скорее всего, так, но, насколько я помню, это не означает, что попытка / исключение влечет за собой изменение способа, которым механизм удаления мусора Компилятор работает, поскольку он рекурсивно очищает выделение памяти объекта из стека. В этом случае может не быть очищаемого объекта, или цикл for может представлять собой замыкание, которое механизм сбора мусора признает достаточным для обеспечения применения другого метода сбора. Наверное, нет, но я подумал, что стоит упомянуть, потому что я не видел, чтобы это обсуждалось где-либо еще.

миллер горилла
источник