Я написал некоторый код для тестирования воздействия 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.
Ответы:
Один из инженеров Roslyn, который специализируется на понимании оптимизации использования стека, взглянул на это и сообщил мне, что существует проблема во взаимодействии между способом, которым компилятор C # генерирует локальные хранилища переменных, и способом JIT , которым регистрирует компилятор планирование в соответствующем коде x86. Результатом является неоптимальная генерация кода на загрузках и хранилищах местных жителей.
По какой-то непонятной для всех нас причине проблематичный путь генерации кода избегается, когда JITter знает, что блок находится в защищенной от попыток области.
Это довольно странно. Мы свяжемся с командой JITter и посмотрим, сможем ли мы ввести ошибку, чтобы они могли это исправить.
Кроме того, мы работаем над улучшением для Roslyn алгоритмов компиляторов C # и VB, чтобы определить, когда локальные объекты можно сделать «эфемерными», то есть просто помещать их в стек, а не выделять определенное место в стеке для продолжительность активации. Мы полагаем, что JITter сможет лучше распределять регистры, и вообще, если мы дадим ему подсказки о том, когда местные жители могут быть «мертвыми» раньше.
Спасибо за то, что обратили на это наше внимание, и приносим извинения за странное поведение.
источник
То, как вы рассчитываете время, выглядит для меня довольно неприятно. Было бы гораздо разумнее просто рассчитать время всего цикла:
Таким образом, вы не находитесь во власти крошечных таймингов, арифметики с плавающей запятой и накопленной ошибки.
Сделав это изменение, посмотрите, не является ли версия без улова более медленной, чем версия с уловом.
РЕДАКТИРОВАТЬ: Хорошо, я попробовал это сам - и я вижу тот же результат. Очень странно. Я задавался вопросом, отключает ли try / catch какое-то плохое встраивание, но используя
[MethodImpl(MethodImplOptions.NoInlining)]
вместо этого не помогло ...В принципе, вам нужно взглянуть на оптимизированный код JITted в cordbg, я подозреваю ...
РЕДАКТИРОВАТЬ: еще несколько бит информации:
n++;
строки по-прежнему улучшает производительность, но не настолько, как размещение всего блокаArgumentException
в моих тестах), это все еще быстроWeird ...
РЕДАКТИРОВАТЬ: Хорошо, у нас есть разборка ...
Для этого используется компилятор C # 2 и .NET 2 (32-битная) CLR, дизассемблируется с помощью mdbg (поскольку на моей машине нет cordbg). Я по-прежнему вижу те же эффекты производительности, даже под отладчиком. Быстрая версия использует
try
блок вокруг всего между объявлениями переменных и оператором возврата, используя толькоcatch{}
обработчик. Очевидно, что медленная версия такая же, за исключением без try / catch. Код вызова (т. Е. Main) одинаков в обоих случаях и имеет одинаковое представление ассемблера (поэтому это не проблема с встраиванием).Разобранный код для быстрой версии:
Разобранный код для медленной версии:
В каждом случае
*
показывается, где отладчик вводится простым «пошаговым вводом».РЕДАКТИРОВАТЬ: Хорошо, теперь я просмотрел код, и я думаю, что я вижу, как работает каждая версия ... и я считаю, что более медленная версия медленнее, потому что она использует меньше регистров и больше места в стеке. Для небольших значений
n
это возможно быстрее, но когда цикл занимает большую часть времени, он медленнее.Возможно, блок try / catch заставляет сохранять и восстанавливать больше регистров, поэтому JIT использует их и для цикла ... что в целом повышает производительность. Не ясно, является ли разумным решение для JIT не использовать столько регистров в «нормальном» коде.
РЕДАКТИРОВАТЬ: Только что попробовал это на моей машине x64. CLR x64 намного быстрее (примерно в 3-4 раза быстрее) CLR x86 в этом коде, а в x64 блок try / catch не имеет заметного различия.
источник
esi,edi
для одного из длинных вместо стека. Он используетebx
в качестве счетчика, где используется медленная версияesi
.Разборки Джона показывают, что различие между двумя версиями состоит в том, что быстрая версия использует пару registers (
esi,edi
) для хранения одной из локальных переменных , в отличие от медленной версии.JIT-компилятор делает различные предположения относительно использования регистров для кода, который содержит блок try-catch, по сравнению с кодом, который этого не делает. Это заставляет его делать разные варианты выделения регистров. В этом случае это благоприятствует коду с блоком try-catch. Разный код может привести к противоположному эффекту, поэтому я бы не считал это универсальной техникой ускорения.
В конце концов, очень трудно сказать, какой код будет работать быстрее всего. Нечто подобное распределению регистров и факторам, влияющим на него, является настолько низкоуровневыми деталями реализации, что я не вижу, как какой-то конкретный метод мог бы надежно создавать более быстрый код.
Например, рассмотрим следующие два метода. Они были адаптированы из реального примера:
Один является общей версией другого. Замена общего типа
StructArray
сделало бы методы идентичными. ПосколькуStructArray
это тип значения, он получает собственную скомпилированную версию универсального метода. Тем не менее, фактическое время работы значительно больше, чем у специализированного метода, но только для x86. Для x64 тайминги в значительной степени идентичны. В других случаях я наблюдал различия и для x64.источник
Это похоже на случай, когда сошло плохо. На ядре 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, потому что джиттер обладает гораздо большей гибкостью при выборе регистров.
источник
Я бы поставил это в качестве комментария, так как я действительно не уверен, что это, скорее всего, так, но, насколько я помню, это не означает, что попытка / исключение влечет за собой изменение способа, которым механизм удаления мусора Компилятор работает, поскольку он рекурсивно очищает выделение памяти объекта из стека. В этом случае может не быть очищаемого объекта, или цикл for может представлять собой замыкание, которое механизм сбора мусора признает достаточным для обеспечения применения другого метода сбора. Наверное, нет, но я подумал, что стоит упомянуть, потому что я не видел, чтобы это обсуждалось где-либо еще.
источник