Это короткий отрывок из главы 2 (Измерение производительности) Pro .NET Performance , которая должна появиться в августе 2012 года. Возможно, я опубликую еще несколько из них до и после выхода книги.
Цель состоит в том, чтобы определить «что быстрее» — использовать ключевое слово is, а затем приводить к нужному типу или использовать ключевое слово as и полагаться на результат. Вот тестовый класс для этого сравнения, которое я видел в последнее время:
//Test class class Employee { public void Work() {} } //Fragment 1 – casting safely and then checking for null static void Fragment1(object obj) { Employee emp = obj as Employee; if (emp != null) { emp.Work(); } } //Fragment 2 – first checking the type and then casting static void Fragment2(object obj) { if (obj is Employee) { Employee emp = obj as Employee; emp.Work(); } } A rudimentary benchmarking framework might go along the following lines: static void Main() { object obj = new Employee(); Stopwatch sw = Stopwatch.StartNew(); for (int i = 0; i < 500; i++) { Fragment1(obj); } Console.WriteLine(sw.ElapsedTicks); sw = Stopwatch.StartNew(); for (int i = 0; i < 500; i++) { Fragment2(obj); } Console.WriteLine(sw.ElapsedTicks); }
Это не убедительный микробенчмарк, хотя результаты довольно воспроизводимы. Чаще всего выход составляет 4 такта для первого цикла и 200-400 тиков для второго цикла. Это может привести к выводу, что первый фрагмент в 50-100 раз быстрее. Однако в этом измерении есть существенные ошибки и вытекающий из этого вывод:
- Цикл выполняется только один раз, и 500 итераций недостаточно для того, чтобы сделать какие-либо значимые выводы — для выполнения всего теста требуется незначительное количество времени, поэтому на него могут влиять многие факторы окружающей среды.
- Не было предпринято никаких усилий, чтобы предотвратить оптимизацию, поэтому JIT-компилятор мог встроить и отбросить оба цикла измерений полностью.
- В Fragment1 и FRAGMENT2 методы измерения не только стоимости есть и в качестве ключевых слов, но и стоимость вызова метода (к фрагменту N сам метод!). Это может быть актёрский состав, который вызывает метод значительно дороже, чем остальная часть работы.
Решая эти проблемы, следующий микробенч более точно отображает фактические затраты на обе операции:
class Employee { //Prevent inlining this method [MethodImpl(MethodImplOptions.NoInlining)] public void Work() {} } static void Measure(object obj) { const int OUTER_ITERATIONS = 10; const int INNER_ITERATIONS = 100000000; for (int i = 0; i < OUTER_ITERATIONS; ++i) { Stopwatch sw = Stopwatch.StartNew(); for (int j = 0; j < INNER_ITERATIONS; ++j) { Employee emp = obj as Employee; if (emp != null) emp.Work(); } Console.WriteLine(sw.ElapsedMilliseconds); } for (int i = 0; i < OUTER_ITERATIONS; ++i) { Stopwatch sw = Stopwatch.StartNew(); for (int j = 0; j < INNER_ITERATIONS; ++j) { if (obj is Employee) { Employee emp = obj as Employee; emp.Work(); } } Console.WriteLine(sw.ElapsedMilliseconds); } }
Результаты на рабочем столе (после отбрасывания первой итерации) составляли около 410 мс для первого цикла и 440 мс для второго цикла — надежное и воспроизводимое различие в производительности, которое могло бы убедить вас в том, что на самом деле более эффективно использовать только ключевое слово as для слепки и проверки.
Однако загадки еще не закончились. Если мы добавим виртуальный модификатор в метод Work , разница в производительности полностью исчезнет , даже если мы увеличим количество итераций. Это не может быть объяснено достоинствами или недостатками нашей системы микропроцессорного тестирования — это результат проблемной области. Невозможно понять это поведение без перехода на уровень ассемблера и проверки цикла, сгенерированного компилятором JIT в обоих случаях. Сначала перед виртуальным модификатором:
; Disassembled loop body – the first loop mov edx,ebx mov ecx,163780h ;MT of Employee call clr!JIT_IsInstanceOfClass test eax,eax je WRONG_TYPE mov ecx,eax call dword ptr ds:[163774h] ;Employee.Work() WRONG_TYPE: ; Disassembled loop body – the second loop mov edx,ebx mov ecx,163780h ;MT of Employee call clr!JIT_IsInstanceOfClass test eax,eax je WRONG_TYPE mov ecx,ebx cmp dword ptr [ecx],ecx call dword ptr ds:[163774h] ;Employee.Work() WRONG_TYPE:
Последовательность инструкций, передаваемая компилятором JIT для вызова не виртуального метода и вызова виртуального метода, обсуждалась в одном из моих постов более пяти лет назад . При вызове не виртуального метода JIT-компилятор должен выдать инструкцию, которая гарантирует, что мы не выполняем вызов метода для нулевой ссылки. Инструкция CMP во втором цикле выполняет эту задачу. В первом цикле JIT-компилятор достаточно умен, чтобы оптимизировать эту проверку, поскольку непосредственно перед вызовом выполняется проверка нулевой ссылки на результат приведения ( if (emp! = Null) …). Во втором цикле эвристики оптимизации JIT-компилятора недостаточно для оптимизации проверки (хотя это было бы столь же безопасно), и эта дополнительная инструкция отвечает за дополнительные 7-8% снижения производительности.
Однако после добавления виртуального модификатора JIT-компилятор генерирует абсолютно одинаковый код в обоих телах цикла:
; Disassembled loop body – both cases mov edx,ebx mov ecx,1A3794h ;MT of Employee call clr!JIT_IsInstanceOfClass test eax,eax je WRONG_TYPE mov ecx,eax mov eax,dword ptr [ecx] mov eax,dword ptr [eax+28h] call dword ptr [eax+10h] WRONG_TYPE:
Причина в том, что при вызове виртуального метода нет необходимости явно выполнять проверку нулевой ссылки — это присуще последовательности диспетчеризации метода . Когда тела цикла идентичны, то же самое происходит и с результатами синхронизации.
Этот небольшой пример не должен отговаривать вас от написания собственных микропроцессоров. Однако худшая оптимизация производительности — это та, которая основана на неправильных измерениях; К сожалению, ручной тест часто приводит к этой ловушке.