Как стать автором
Обновить
136.48
JUG Ru Group
Конференции для Senior-разработчиков

Подводные камни бенчмаркинга в .NET: фрагмент книги Андрея Акиньшина

Время на прочтение12 мин
Количество просмотров6.1K

Андрей Акиньшин @DreamWalker хорошо известен в .NET-сообществе: он мейнтейнер BenchmarkDotNet и perfolizer, член программного комитета нашей конференции DotNext, автор книги Pro .NET Benchmarking о том, как правильно бенчмаркать.

А теперь эта книга есть и на русском языке — ее перевод подготовило издательство «Питер». Сделаем важную оговорку: переводил не сам Андрей, так что русскоязычная терминология может отличаться от той, которую выбрал бы он, и «каноническим авторским вариантом» по-прежнему остаётся англоязычный. Но наверняка для многих важна сама возможность прочитать это на родном языке, поэтому с любезного разрешения Андрея и издательства мы публикуем на Хабре фрагмент перевода.

В книге много сказано о бенчмаркинге в целом, поэтому она может быть полезна для самых разных разработчиков, но мы приведём фрагмент о том, что специфично для .NET.


Подводные камни, специфичные для .NET

.NET — прекрасная платформа. У любой среды исполнения .NET есть множество прекрасных оптимизаций, ускоряющих ваши приложения. Если вы хотите написать очень быструю программу, эти оптимизации — ваши лучшие друзья. Если же вы хотите написать надежный бенчмарк, то они — ваши заклятые враги. Среда исполнения .NET не понимает, что вы хотите измерить производительность, — она пытается выполнять программу как можно быстрее.

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

Развертывание циклов

Мы уже знаем, что очень быстрые методы следует заворачивать в циклы, чтобы корректно измерить время их работы. Известно ли вам, что происходит с подобными циклами во время компиляции?

Рассмотрим следующий простой цикл:

for (int i = 0; i < 10000; i++) 
  Foo();

Если мы скомпилируем и запустим его в режиме релиза и посмотрим на полученный ассемблерный код для LegacyJIT-x86, получится примерно так:

LOOP:
call dword ptr ds:[5B0884h] ; Foo(); inc esi ; i++
cmp esi,2710h               ; if (i < 10000)
jl LOOP                     ; Go to LOOP

Этот код выглядит довольно очевидным. Теперь посмотрим на код сборки для LegacyJIT-x64:

LOOP:
call 00007FFC39DB0FA8    ; Foo();
call 00007FFC39DB0FA8    ; Foo();
call 00007FFC39DB0FA8    ; Foo();
call 00007FFC39DB0FA8    ; Foo();
add ebx,4                ; i += 4
cmp ebx,2710h            ; if (i < 10000)
jl 00007FFC39DB4787      ; Go to LOOP

Что здесь произошло? Наш цикл развернули! LegacyJIT-x64 выполнил развертывание цикла, и теперь код выглядит следующим образом:

for (int i = 0; i < 10000; i += 4)
{
  Foo();
  Foo();
  Foo();
  Foo();
}

О разных сложных оптимизациях JIT подробнее рассказывается в главе 7. Сейчас же вам нужно знать, что контролировать то, какие циклы будут развернуты, невозможно. В .NET Framework 4.6, LegacyJIT-x86 и RyuJIT-x64 не умеют применять такую оптимизацию. Таким навыком обладает только LegacyJIT-x64. Но он может развернуть цикл, только если количество итераций известно заранее и делится на 2, 3 или 4 (LegacyJIT-x64 старается выбрать максимальный делитель). Однако использовать специальные знания об оптимизации компилятора JIT — не самая удачная идея: все может измениться в любой момент. Оптимальное решение — хранить количество итераций в дополнительном поле, чтобы компилятор JIT не мог применить оптимизацию.

Действительно ли об этом нужно беспокоиться? Давайте проверим!

Плохой бенчмарк

Допустим, мы хотим узнать, сколько времени нужно, чтобы выполнить пустой цикл с 1 000 000 001 и 1 000 000 002 итерациями. Это очередной абсолютно бессмысленный эксперимент, но в то же время это минимальный вариант воспроизведения обсуждаемой нами проблемы (почему-то многие разработчики любят измерять пустые циклы):

var stopwatch1 = Stopwatch.StartNew();
for (int i = 0; i < 1000000001; i++)
{
}
stopwatch1.Stop();

var stopwatch2 = Stopwatch.StartNew();
for (int i = 0; i < 1000000002; i++)
{
}
stopwatch2.Stop()

Console.WriteLine(stopwatch1.ElapsedMilliseconds + " vs. " +
                  stopwatch2.ElapsedMilliseconds);

Пример приблизительных результатов для LegacyJIT-x86 и LegacyJIT-x64 приведен в табл. 2.3.

Таблица 2.3. Результаты для пустых циклов с константами

Количество итераций

LegacyJIT-x86

LegacyJIT-x64

1 000 000 001

~ 360 мс

~ 360 мс

1 000 000 002

~ 360 мс

~ 120 мс

Как такое возможно? Интереснее всего именно то, почему на LegacyJIT-x64 1 000 000 002 итерации производятся в три раза быстрее, чем 1 000 000 001.

Все дело в развертывании: 1 000 000 001 не делится на 2, 3 и 4, поэтому в данном случае LegacyJIT-x64 не может произвести развертывание. Но второй цикл с 1 000 000 002 итерациями развернуть возможно, потому что это число делится на 3! Оно делится и на 2, но компилятор JIT выбирает максимальный делитель для развертывания цикла. Взглянем на исходный код на ассемблере для обоих циклов:

; 1000000001 iterations
LOOP:
inc      eax           ; i++
cmp      eax,3B9ACA01h ; if (i < 1000000001)
jl       LOOP          ; Go to LOOP

; 1000000002 iterations
LOOP:
add      eax,3         ; i += 3
cmp      eax,3B9ACA02h ; if (i < 1000000002)
jl       LOOP          ; Go to LOOP

Во втором случае мы видим инструкцию add eax,3, увеличивающую счетчик на 3: это развертывания цикла в действии! Теперь становится очевидно, почему второй цикл работает в три раза быстрее.

Бенчмарк получше

Мы можем перехитрить JIT и хранить количество итераций в отдельных полях. Будьте внимательны, это должны быть именно поля, а не константы!

private int N1 = 1000000001, N2 = 1000000002;

public void Measure()
{
  var stopwatch1 = Stopwatch.StartNew();
  for (int i = 0; i < N1; i++)
  {
  }
  stopwatch1.Stop();
  
  var stopwatch2 = Stopwatch.StartNew();
  for (int i = 0; i < N2; i++)
  {
  }
  stopwatch2.Stop();
}

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

Таблица 2.4. Результаты для пустых циклов с переменными

Количество итераций

LegacyJIT-x86

LegacyJIT-x64

1 000 000 001

~ 360 мс

~ 360 мс

1 000 000 002

~ 360 мс

~ 360 мс

Да, конфигурация LegacyJIT-x64/1000000002 не такая быстрая, как в первом случае. Но теперь это честное сравнение: здесь мы не стремимся к максимальной производительности, а пытаемся сравнить производительность двух фрагментов кода.

СОВЕТ

Используйте в циклах переменные вместо констант.

Вы хотите сравнить две разные реализации алгоритма, но цикл — это просто способ получить осмысленный результат для очень быстрых операций, на самом деле он не является частью измеряемой логики. Конечно же, мы не хотим, чтобы количество итераций цикла влияло на усредненное время работы. Так что лучше сделать N1 и N2 переменными, а не константами. Если вы и дальше будете внимательно читать эту главу, то заметите, что мы продолжаем использовать в циклах константы. Делаем это только для упрощения и берем константы, которые не делятся на 2 или 3. (LegacyJIT-x64, ты не сможешь их развернуть!) Все эти примеры не являются настоящими бенчмарками — это только иллюстрации подводных камней бенчмаркинга. Поэтому для подобной демонстрации применять константы допустимо, однако лучше так не делать в настоящих бенчмарках.

Теперь мы знаем, как предотвратить развертывание циклов, но это не единственная оптимизация среды исполнения. В следующем разделе разберемся, как предотвратить удаление тела цикла.

Удаление неисполняемого кода

Современные компиляторы очень умны. В большинстве случаев они даже умнее разработчиков, которые пытаются что-то измерить с помощью бенчмарка. В типичном бенчмарке частенько присутствуют вызовы методов, возвращаемое значение которых не используется, ведь в бенчмаркинге нам неважен результат их работы, важно только время этой работы. Если измеренный код не производит никаких наблюдаемых эффектов, компилятор может его выкинуть. Эта оптимизация называется удалением неисполняемого кода (dead code elimination, DCE). Рассмотрим пример, наглядно показывающий эту оптимизацию.

Плохой бенчмарк

Найдем квадратные корни всех чисел от 0 до 100 000 000:

double x = 0;
for (int i = 0; i < 100000001; i++) 
  Math.Sqrt(x);

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

double x = 0;

var stopwatch = Stopwatch.StartNew();
for (int i = 0; i < 100000001; i++)
  Math.Sqrt(x);
stopwatch.Stop();

var stopwatch2 = Stopwatch.StartNew();
for (int i = 0; i < 100000001; i++);
stopwatch2.Stop();

var target = stopwatch.ElapsedMilliseconds;
var overhead = stopwatch2.ElapsedMilliseconds;
var result = target - overhead;
Console.WriteLine("Target = " + target + "ms");
Console.WriteLine("Overhead = " + overhead + "ms");
Console.WriteLine("Result = " + result + "ms");

Пример результата (Windows, .NET Framework, RyuJIT-x64, режим релиза):

Target = 37ms 
Overhead = 37ms 
Result = 0ms

Ура, Math.Sqrt работает мгновенно! Мы можем исполнять Math.Sqrt сколько захотим без затрат производительности! Давайте запустим еще раз:

Target = 36ms 
Overhead = 37ms 
Result = -1ms

Ура, вызовы Math.Sqrt работают за отрицательное время! Если мы добавим побольше таких вызовов, то программа ускорится! Хотя… Не смущает ли вас такой  результат? Взглянем на ассемблерный код нашего цикла:

; for (int i = 0; i < 100000001; i++)
; Math.Sqrt(x);
LOOP:
inc    eax        ; i++
cmp    eax,2710h  ; if (i < 10000)
jl     LOOP       ; Go to LOOP

Ага! Компилятор JIT применил здесь волшебную оптимизацию! Вы могли заметить, что мы никак не используем результаты Math.Sqrt. Этот код может быть спокойно удален. Увы, данная оптимизация портит наш бенчмарк. В обоих случаях мы измеряем пустой цикл. Из-за естественного шума появляется вариативность в измерениях, поэтому получить отрицательный результат нормально — это просто означает, что одно измерение больше другого.

Но мы все еще хотим измерить затраты производительности на Math.Sqrt. Как же улучшить наш бенчмарк?

Бенчмарк получше

Типичное обходное решение — каким-то образом использовать этот результат (давайте перепишем первую часть плохого бенчмарка):

double x = 0, y = 0;

var stopwatch = Stopwatch.StartNew(); 
for (int i = 0; i < 100000001; i++)
  y += Math.Sqrt(x); 
stopwatch.Stop(); 
Console.WriteLine(y);

Теперь вызовы к Math.Sqrt нельзя удалить, потому что нам нужен их результат, чтобы вывести сумму квадратных корней. Конечно, мы добавляем и небольшие накладные расходы из-за операции y +=. Это часть затрат на инфраструктуру бенчмаркинга. Проверим, как это работает:

Target = 327ms 
Overhead = 37ms 
Result = 290ms

Теперь Result — положительное число (290 мс), и в этом больше смысла.

СОВЕТ

Всегда используйте результаты своих вычислений.

Современные компиляторы умны, но мы должны быть умнее! В наших бенчмарках не должно быть кода, который можно выбросить. Единственный способ добиться этого — каким-то образом использовать возвращаемые значения измеряемых методов. Roslyn и JIT не должны знать, что на самом деле эти результаты нам не нужны. Самый простой способ — собрать все вычисленные значения и сохранить их в какое-нибудь поле. Если вы применяете локальную переменную, то значение этой переменной также должно быть как-то использовано после измерений (Console.WriteLine подойдет, если вас не волнует, что в результатах программы появляются лишние строчки).

Осторожно: любой код, предотвращающий удаление неиспользуемого кода, — тоже часть инфраструктуры бенчмарка и увеличивает общее время. Вы должны быть уверены в том, что это ограничение незначительно и не сильно повлияет на измерения. Представьте, что в результате у вас получается строка. Как ее использовать? Мы можем, например, добавить ее к другой строке следующим образом:

string StringOperation() { /* ... / }
 
// Бенчмарк
var stopwatch = Stopwatch.StartNew(); 
string acc = "";
for (int i = 0; i < N; i++) 
  acc += StringOperation();
stopwatch.Stop();

Стоит ли так хранить результаты бенчмарка? Нет, потому что накладные расходы на конкатенацию строк очень велики. Более того, они зависят от количества итераций: каждая следующая итерация занимает больше времени, чем предыдущая, потому что растет длина строки acc. Поэтому с каждой итерацией возрастает количество выделяемой памяти и увеличивается количество символов для копирования. Как же улучшить этот бенчмарк? В качестве одного из вариантов мы можем суммировать не сами строки, а их длины:

string StringOperation() { / ... */ }

// Бенчмарк
var stopwatch = Stopwatch.StartNew(); 
int acc = 0;
for (int i = 0; i < N; i++)
     acc += StringOperation().Length; 
stopwatch.Stop();

Так гораздо лучше, потому что сложение целых чисел и получение длины строки обычно работает гораздо быстрее, чем любые операции со строками. В большинстве случаев значительных накладных расходов при этом не будет, а данный прием идеально решает задачу предотвращения удаления ненужного кода: компилятор не может выкинуть вызовы StringOperation(), потому что мы используем их результат!

Удаление ненужного кода — не единственная оптимизация, которая может удалить часть логики из программы. В следующем разделе обсудим еще одну замечательную оптимизацию, которая может упростить код программы. 

Свертка констант

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

int Mul() => 2 * 3;

Выглядит ли это как метод, который мы действительно можем использовать для бенчмаркинга умножения? Чтобы узнать, скомпилируем его в режиме релиза и посмотрим на IL-код:

ldc.i4.6 
ret

ldc.i4.6 означает «Положить 6 на стек как int32», ret — «Взять значение из стека и вернуть его из метода».

Здесь вы видите результат умножения (6), жестко закодированный внутри программы. Компилятор C# достаточно умен, чтобы заранее высчитать такие выражения во время компиляции. Название этой оптимизации — свертка констант (constant folding). Она работает для всех констант, включая строки (например, "a" + "b" будет скомпилировано в "ab"). Эта оптимизация хороша с точки зрения производительности, но не очень хороша с точки зрения бенчмаркинга. Если мы хотим измерить производительность арифметических операций, мы должны быть уверены в том, что компилятор не сможет заранее выполнить никаких вычислений. Для этого мы можем хранить аргументы в отдельных полях:

private int a = 2, b = 3; 
public int Mul() => a * b;

После применения данного рефакторинга в IL-коде появится команда mul:

ldarg.0 
ldfld     a 
ldarg.0 
ldfld     b 
mul

ret

ldarg.0 означает «Положить нулевой аргумент на стек». Нулевым аргументом является текущий объект (this). Следующая команда ldfld <field> означает «Взять объект из стека, получить заданное поле и положить значение поля обратно на стек». mul означает «Перемножить два верхних значения из стека». Таким образом, в этом коде мы кладем на стек this.a, затем this.b, а потом берем из стека два последних значения, перемножаем их, кладем результат обратно на стек и возвращаем его из функции.

Свертка констант может казаться простой и предсказуемой оптимизацией, но это не всегда так. Рассмотрим еще один интересный пример.

Плохой бенчмарк

Еще одна загадка: какой метод быстрее на RyuJIT-x64 (.NET Framework 4.6 без обновлений)?

public double Sqrt13()
{
  return
    Math.Sqrt(1) + Math.Sqrt(2) + Math.Sqrt(3) +
    Math.Sqrt(4) + Math.Sqrt(5) + Math.Sqrt(6) +
    Math.Sqrt(7) + Math.Sqrt(8) + Math.Sqrt(9) +
    Math.Sqrt(10) + Math.Sqrt(11) + Math.Sqrt(12) +
    Math.Sqrt(13);
}
public double Sqrt14()
{
  return
    Math.Sqrt(1) + Math.Sqrt(2) + Math.Sqrt(3)
    + Math.Sqrt(4) + Math.Sqrt(5) + Math.Sqrt(6)
    + Math.Sqrt(7) + Math.Sqrt(8) + Math.Sqrt(9)
    + Math.Sqrt(10) + Math.Sqrt(11) + Math.Sqrt(12)
    + Math.Sqrt(13) + Math.Sqrt(14);
}

Если мы внимательно измерим каждый метод, результат получится похожим на то, что показано в табл. 2.5.

Таблица 2.5. Результаты Sqrt13 и Sqrt14 на RyuJIT-x64

Метод

Время

Sqrt13

~ 91 нс

Sqrt14

0 нс

Выглядит очень странно. Мы добавили дополнительную операцию с вычислением квадратного корня, и она улучшила производительность кода. И не просто улучшила, а сделала его выполнение мгновенным! Разве такое возможно? Пора посмотреть на IL-код каждого из методов:

; Sqrt13
vsqrtsd     xmm0,xmm0,mmword ptr [7FF94F9E4D28h]
vsqrtsd     xmm1,xmm0,mmword ptr [7FF94F9E4D30h]
vaddsd      xmm0,xmm0,xmm1
vsqrtsd     xmm1,xmm0,mmword ptr [7FF94F9E4D38h]
vaddsd      xmm0,xmm0,xmm1
vsqrtsd     xmm1,xmm0,mmword ptr [7FF94F9E4D40h]
vaddsd      xmm0,xmm0,xmm1
vsqrtsd     xmm1,xmm0,mmword ptr [7FF94F9E4D48h]
vaddsd      xmm0,xmm0,xmm1
vsqrtsd     xmm1,xmm0,mmword ptr [7FF94F9E4D50h]
vaddsd      xmm0,xmm0,xmm1
vsqrtsd     xmm1,xmm0,mmword ptr [7FF94F9E4D58h]
vaddsd      xmm0,xmm0,xmm1
vsqrtsd     xmm1,xmm0,mmword ptr [7FF94F9E4D60h]
vaddsd      xmm0,xmm0,xmm1
vsqrtsd     xmm1,xmm0,mmword ptr [7FF94F9E4D68h]
vaddsd      xmm0,xmm0,xmm1
vsqrtsd     xmm1,xmm0,mmword ptr [7FF94F9E4D70h]
vaddsd      xmm0,xmm0,xmm1
vsqrtsd     xmm1,xmm0,mmword ptr [7FF94F9E4D78h]
vaddsd      xmm0,xmm0,xmm1
vsqrtsd     xmm1,xmm0,mmword ptr [7FF94F9E4D80h]
vaddsd      xmm0,xmm0,xmm1
vsqrtsd     xmm1,xmm0,mmword ptr [7FF94F9E4D88h]
vaddsd      xmm0,xmm0,xmm1
ret

; Sqrt14
vmovsd      xmm0,qword ptr [7FF94F9C4C80h]
ret

vsqrtsd вычисляет квадратный корень значения с плавающей запятой, vaddsd складывает значения с плавающей запятой, а vmovsd перемещает значение с плавающей запятой. Таким образом, Sqrt13 каждый раз высчитывает всю сумму, а Sqrt14 просто выдает константу.

Ага! Кажется, RyuJIT-x64 применил к  Sqrt14  оптимизацию  свертки  констант. Но почему этого не происходит с Sqrt13?

Если бы вы сами были JIT-компилятором, то жилось бы вам очень непросто. Вы бы знали столько потрясающих оптимизаций, но у вас было бы так мало времени, чтобы все их применить (никто не хочет возникновения проблем с производительностью из-за JIT-компиляции). Поэтому нужен компромисс между временем JIT- компиляции и количеством оптимизаций. Как решить, когда и какую оптимизацию применять? У RyuJIT-x64 есть набор эвристических алгоритмов, помогающих принимать подобные решения. В частности, если мы работаем с небольшим методом, можно пропустить некоторые оптимизации, потому что, скорее всего, метод и так будет достаточно быстрым. Если метод довольно большой, мы можем провести больше времени на стадии JIT-компиляции, чтобы улучшить скорость исполнения метода. В нашем примере добавление Math.Sqrt(14) — момент достижения эвристического порога: в этой точке RyuJIT решает применить дополнительную оптимизацию.

О подобных вещах стоит знать, но использовать эти знания в коде готового приложения — не лучшая идея. Не стоит пытаться улучшить производительность приложения, добавляя дополнительные вызовы Math.Sqrt в разных местах. Детали реализации JIT-компилятора можно изменить в любой момент. К примеру, вышеописанная проблема со сверткой констант была обсуждена и исправлена, поэтому ее нельзя воспроизвести на .NET Framework 4.7+ (и Sqrt13, и Sqrt14 будут работать за нулевое время из-за свертки констант).

Давайте улучшим наш бенчмарк так, чтобы он работал корректно вне зависимости от версии .NET Framework.

Бенчмарк получше

Лучший способ избежать свертки констант довольно прост — не использовать константы. Можно переписать наш код, добавив дополнительные поля для хранения используемых значений:

public double x1 = 1, x2 = 2, x3 = 3, x4 = 4, x5 = 5, x6 = 6,
              x7 = 7, x8 = 8, x9 = 9, x10 = 10, x11 = 11,
              x12 = 12, x13 = 13, x14 = 14;
public double Sqrt14()
{
  return
     Math.Sqrt(x1) + Math.Sqrt(x2) + Math.Sqrt(x3) +
     Math.Sqrt(x4) + Math.Sqrt(x5) + Math.Sqrt(x6) +
     Math.Sqrt(x7) + Math.Sqrt(x8) + Math.Sqrt(x9) +
     Math.Sqrt(x10) + Math.Sqrt(x11) + Math.Sqrt(x12) +
     Math.Sqrt(x13) + Math.Sqrt(x14);
}

Теперь RyuJIT не сможет применить свертку констант, потому что в теле метода больше нет никаких констант.

СОВЕТ

Не используйте константы в своих бенчмарках.

Все просто: если у вас нет констант, свертку констант применить нельзя. Если вы хотите передать нужным методам какие-то аргументы, лучше заведите для них отдельные поля. Данный подход еще и улучшит дизайн ваших бенчмарков. Обычно при разных входных данных получаются разные замеры времени. Если вы используете параметры вместо жестко закодированных значений, в будущем станет проще проверять другие значения ввода.

У .NET много способов удаления различных частей кода. В следующем разделе мы обсудим еще один из них.


Андрей не только сам несёт .NET-знания в массы, но и помогает другим это делать, участвуя в программном комитете DotNext. Так что, если этот пост для вас актуален, то думаем, что и на нашей конференции вам будет интересно.

В ближайший раз она пройдёт 7-8 апреля онлайн, информация и билеты — на сайте. Программа ещё не сформирована, но когда отвечают за неё люди вроде Андрея, сомневаться в ней не приходится :)

Теги:
Хабы:
+27
Комментарии3

Публикации

Информация

Сайт
jugru.org
Дата регистрации
Дата основания
Численность
51–100 человек
Местоположение
Россия
Представитель
Алексей Федоров