Микрохронометраж

128

Некоторые проблемы и вопросы производительности могут быть решены только с применением ручных способов измерения. Например, вам может потребоваться обосновать выбор в пользу класса 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 раз быстрее. Однако данный способ измерений ошибочен и, соответственно, ошибочен вывод, сделанный на основе полученных результатов:

Исправим эти проблемы, как показано в следующем фрагменте, который позволяет получить более точную картину стоимости обеих операций:

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:

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

Рекомендации по проведению хронометража

Для успешного проведения хронометража старайтесь придерживаться следующих рекомендаций:

Когда тестируемый код будет полностью готов и достаточно надежно измеряет именно те характеристики, для измерения которых он предназначался, необходимо потратить некоторое время на подготовки окружения для хронометража:

  1. В процессе выполнения хронометража не должны запускаться никакие другие приложения. Сетевые операции, операции с файлами или другие типы внешней активности должны быть минимизированы (например, отключением сетевой карты или остановкой ненужных служб).

  2. При проведении хронометража кода, создающего большое количество объектов, желательно максимально уменьшить влияние сборщика мусора. Желательно, чтобы сборка мусора выполнялась до и после критически важных итераций, чтобы уменьшить их взаимовлияние.

  3. Аппаратное обеспечение тестовой системы по своим характеристикам должно быть похоже на аппаратное обеспечение промышленной системы. Например, тесты, интенсивно выполняющие дисковые операции, связанные с перемещением головок по диску, будут выполняться намного быстрее на твердотельном накопителе, чем на обычном механическом жестком диске. (То же относится к графическим картам, процессорам со специфическими возможностями, такими как поддержка набора инструкций SIMD, архитектуре памяти и другим аппаратным особенностям.)

Наконец, необходимо особое внимание уделить коду, реализующему тестирование. Ниже приводится несколько правил, которые следует помнить:

Разрешение измерения - это минимальный интервал времени, различаемый механизмом измерения. Если в документации сообщается, что он возвращает результат в виде целого числа, кратного 100 нсек, его разрешающая способность составляет 100 нсек. Однако его точность может быть значительно меньше - после измерения фактического интервала времени 500 нсек он может в одном случае вернуть значение 2*100 нсек, а в другом 7*100 нсек. В этой ситуации мы могли бы принять за верхнюю границу точности 300 нсек. Наконец, точность определяет погрешность механизма измерений. Если физический интервал 5000 нсек от испытания к испытанию измеряется как 5400 нсек с точностью до 100 нсек, можно говорить, что погрешность составляет +8%.

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

Пройди тесты
Лучший чат для C# программистов