Статьи

Делать микро-бенчмаркинг неправильно по неправильным причинам

Это короткий отрывок из главы 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 раз быстрее. Однако в этом измерении есть существенные ошибки и вытекающий из этого вывод:

  1. Цикл выполняется только один раз, и 500 итераций недостаточно для того, чтобы сделать какие-либо значимые выводы — для выполнения всего теста требуется незначительное количество времени, поэтому на него могут влиять многие факторы окружающей среды.
  2. Не было предпринято никаких усилий, чтобы предотвратить оптимизацию, поэтому JIT-компилятор мог встроить и отбросить оба цикла измерений полностью.
  3. В 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:

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

Этот небольшой пример не должен отговаривать вас от написания собственных микропроцессоров. Однако худшая оптимизация производительности — это та, которая основана на неправильных измерениях; К сожалению, ручной тест часто приводит к этой ловушке.