Микрохронометраж
128C# и .NET Framework --- Оптимизация приложений .NET Framework --- Микрохронометраж
Некоторые проблемы и вопросы производительности могут быть решены только с применением ручных способов измерения. Например, вам может потребоваться обосновать выбор в пользу класса StringBuilder, измерить производительность сторонней библиотеки, оптимизировать сложный алгоритм, разворачивая внутренние циклы или помогая JIT-компилятору поместить часто используемые данные в регистры - и при этом может оказаться невозможным использовать профилировщики для измерений из-за их медлительности, сложности или слишком высоких накладных расходов. И хотя микрохронометраж часто полон опасностей, он пользуется большой популярностью. Если вы соберетесь использовать его, мы хотим, чтобы вы выполняли его правильно.
Пример неправильного микрохронометража
Для начала рассмотрим пример неправильно спроектированного микрохронометража, и затем будем улучшать его, пока получаемые результаты не приобретут смысл и не станут коррелировать с нашим знанием проблемы.
Цель - определить, что быстрее - применение ключевого слова is с последующим приведением к требуемому типу или применение ключевого слова as с использованием результата.
public class Program
{
// Тестовый класс
class Employee
{
public void Work() { }
}
// Фрагмент 1 – выполняет безопасное приведение типа
// и проверяет результат на равенство null
static void Fragment1(object obj)
{
Employee emp = obj as Employee;
if (emp != null)
{
emp.Work();
}
}
// Фрагмент 1 – сначала проверяет тип,
// а затем выполняет приведение
static void Fragment2(object obj)
{
if (obj is Employee)
{
Employee emp = obj as Employee;
emp.Work();
}
}
}
Следующие строки выполняют простейший хронометраж:
static void Main(string[] args)
{
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 измеряют не только стоимость выполнения ключевых слов is и as, но также стоимость вызова метода (и самого метода FragmentN); однако может так получиться, что стоимость вызова метода окажется намного дороже стоимости измеряемой операции.
Исправим эти проблемы, как показано в следующем фрагменте, который позволяет получить более точную картину стоимости обеих операций:
public class Program
{
// Тестовый класс
class Employee
{
// Предотвратить оптимизацию этого метода JIT-компилятором
[System.Runtime.CompilerServices.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("As - {0}мс", 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("As с приведением через is - {0}ms", sw.ElapsedMilliseconds);
}
}
static void Main(string[] args)
{
object obj = new Employee();
Measure(obj);
}
}
На моем компьютере (после отброса первой итерации) продолжительность первого цикла составила около 410 мсек и для второго около 440 мсек. Разница в скорости выполнения надежно воспроизводилась от эксперимента к эксперименту, что могло бы служить основанием говорить о более высокой производительности ключевого слова as.
Однако загадки на этом не закончились. Если добавить к методу work модификатор virtual, различия в производительности полностью исчезнут, даже при большом количестве повторений. Такое положение нельзя объяснить достоинствами или недостатками нашего способа измерений, это обусловлено особенностями предметной области. Мы не сможем объяснить такое поведение, не погрузившись на уровень машинного кода и не исследовав реализации обоих циклов, сгенерированные JIT-компилятором. Посмотрим на цикл до добавления модификатора virtual:
; Дизассемблированное тело первого цикла
mov edx,ebx
mov ecx,163780h (MT: Employee)
call clr!JIT_IsInstanceOfClass (705ecfaa)
test eax,eax
je WRONG_TYPE
mov ecx,eax
call dword ptr ds:[163774h] (Employee.Work(), mdToken: 06000001)
WRONG_TYPE:
; Дизассемблированное тело второго цикла
mov edx,ebx
mov ecx,163780h (MT: Employee)
call clr!JIT_IsInstanceOfClass (705ecfaa)
test eax,eax
je WRONG_TYPE
mov ecx,ebx
cmp dword ptr [ecx],ecx
call dword ptr ds:[163774h] (Employee.Work(), mdToken: 06000001)
WRONG_TYPE:
Позже мы более подробно обсудим последовательности машинных инструкций, генерируемых JIT-компилятором для вызова виртуальных и невиртуальных методов. Когда вызывается невиртуальный метод, JIT-компилятор должен сгенерировать инструкции, проверяющие, что вызов не будет выполнен по нулевому адресу. Инструкция cmp во втором цикле решает эту задачу. В первом цикле JIT-компилятор оптимизирует эту проверку, удаляя ее за ненадобностью, потому что проверка результата приведения типа выполняется непосредственно перед вызовом "if (emp != null)". Во втором цикле эвристический алгоритм оптимизации JIT-компилятора не может принять решение об удалении проверки (хотя это вполне безопасно) и эта лишняя инструкция как раз и дает те самые 7-8% накладных расходов.
Однако, после добавления модификатора virtual JIT-компилятор генерирует абсолютно одинаковый код в обоих циклах:
; Дизассемблированное тело обоих циклов
mov edx,ebx
mov ecx,1A3794h (MT: Employee)
call clr!JIT_IsInstanceOfClass (6b24cfaa)
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:
Причина заключается в том, что когда вызывается виртуальный метод, отпадает необходимость явно проверять ссылку - она неявно выполняется последовательностью инструкций выбора метода. Когда оба цикла оказываются идентичными, и результаты хронометража тоже оказываются идентичными.
Рекомендации по проведению хронометража
Для успешного проведения хронометража старайтесь придерживаться следующих рекомендаций:
Тестирование должно выполняться в среде, близкой по своим характеристикам окружению, в котором должно работать разрабатываемое приложение. Например, не следует производить хронометраж метода с коллекциями данных, находящимися в памяти, если в действительности он должен обрабатывать таблицы в базе данных.
Тестовые исходные данные по своей структуре должны быть близки фактическим данным. Например, не следует измерять быстродействие сортировки списка с тремя элементами, если в действительности должна выполняться сортировка списков, содержащих миллионы элементов.
Время выполнения кода поддержки, используемого для настройки окружения, должно быть ничтожно мало, по сравнению со временем выполнения тестируемого кода. Если это невозможно, тогда настройка должна выполняться один раз, а тестируемый код - много раз (как в примере выше с 10 млн. итераций цикла).
Тестируемый код должен выполняться достаточно долго, чтобы ослабить влияние случайных программных и аппаратных флуктуаций. Например, при измерении накладных расходов, связанных с упаковкой значений простых типов в экземпляры классов, одной операции будет явно недостаточно, чтобы получить значимые результаты, и необходимо выполнить большое количество итераций.
Тестируемый код не должен оптимизироваться компилятором языка или JIT-компилятором. Такие оптимизации часто выполняются при компиляции в режиме «Release».
Когда тестируемый код будет полностью готов и достаточно надежно измеряет именно те характеристики, для измерения которых он предназначался, необходимо потратить некоторое время на подготовки окружения для хронометража:
В процессе выполнения хронометража не должны запускаться никакие другие приложения. Сетевые операции, операции с файлами или другие типы внешней активности должны быть минимизированы (например, отключением сетевой карты или остановкой ненужных служб).
При проведении хронометража кода, создающего большое количество объектов, желательно максимально уменьшить влияние сборщика мусора. Желательно, чтобы сборка мусора выполнялась до и после критически важных итераций, чтобы уменьшить их взаимовлияние.
Аппаратное обеспечение тестовой системы по своим характеристикам должно быть похоже на аппаратное обеспечение промышленной системы. Например, тесты, интенсивно выполняющие дисковые операции, связанные с перемещением головок по диску, будут выполняться намного быстрее на твердотельном накопителе, чем на обычном механическом жестком диске. (То же относится к графическим картам, процессорам со специфическими возможностями, такими как поддержка набора инструкций SIMD, архитектуре памяти и другим аппаратным особенностям.)
Наконец, необходимо особое внимание уделить коду, реализующему тестирование. Ниже приводится несколько правил, которые следует помнить:
Результаты первого измерения должны отбрасываться - оно часто отягощено накладными расходами, связанными с работой JIT-компилятора и выполнением других начальных операций. Кроме того, маловероятно, что перед выполнением первой итерации данные и инструкции окажутся в кеше процессора. (Если целью тестирования является выявление эффекта влияния кеша, это правило не должно учитываться.)
Измерения должны повторяться много раз, при этом в качестве результата следует рассматривать не только среднее значение, но также стандартное отклонение (позволяющее оценить разброс результатов относительно среднего значения) и флуктуации между сеансами тестирования.
Накладные расходы на выполнение измерительного цикла должны вычитаться из общих результатов хронометража. Для этого следует выполнить хронометраж пустого цикла, что не так просто, как кажется, потому что JIT-компилятор часто оптимизирует пустые циклы, удаляя их. (Одно из решений состоит в том, чтобы вручную написать пустой цикл на ассемблере.)
Накладные расходы на выполнение измерений должны вычитаться из общих результатов. А сами измерения должны производиться наименее дорогостоящим и наиболее точным способом из доступных - обычно для этой цели можно использовать класс Stopwatch из пространства имен System.Diagnostics.
Вы должны точно знать разрешающую способность и точность измерительного инструмента, используемого в хронометраже. Например, точность Environment.TickCount составляет всего 10-15 мсек, хотя на первый взгляд кажется, что он имеет точность 1 мсек.
Разрешение измерения - это минимальный интервал времени, различаемый механизмом измерения. Если в документации сообщается, что он возвращает результат в виде целого числа, кратного 100 нсек, его разрешающая способность составляет 100 нсек. Однако его точность может быть значительно меньше - после измерения фактического интервала времени 500 нсек он может в одном случае вернуть значение 2*100 нсек, а в другом 7*100 нсек. В этой ситуации мы могли бы принять за верхнюю границу точности 300 нсек. Наконец, точность определяет погрешность механизма измерений. Если физический интервал 5000 нсек от испытания к испытанию измеряется как 5400 нсек с точностью до 100 нсек, можно говорить, что погрешность составляет +8%.
Неудачный пример в начале этой статьи был приведен не для того, чтобы отпугнуть вас от мысли реализовать собственное тестирование. Однако вы должны помнить рекомендации, данные выше, и проектировать реализацию хронометража так, чтобы его результатам можно было доверять. Нет ничего хуже, когда попытки оптимизации основываются на ошибочных измерениях, и к сожалению ручное тестирование часто ведет в эту ловушку.