Можно ли улучшить эту реализацию при тестировании небольших образцов кода на C #?

104

Достаточно часто на SO я обнаруживаю, что тестирую небольшие фрагменты кода, чтобы увидеть, какая реализация является самой быстрой.

Довольно часто я вижу комментарии о том, что код тестирования не учитывает джиттинг или сборщик мусора.

У меня есть следующая простая функция тестирования, которую я медленно развивал:

  static void Profile(string description, int iterations, Action func) {
        // warm up 
        func();
        // clean up
        GC.Collect();

        var watch = new Stopwatch();
        watch.Start();
        for (int i = 0; i < iterations; i++) {
            func();
        }
        watch.Stop();
        Console.Write(description);
        Console.WriteLine(" Time Elapsed {0} ms", watch.ElapsedMilliseconds);
    }

Использование:

Profile("a descriptions", how_many_iterations_to_run, () =>
{
   // ... code being profiled
});

Есть ли в этой реализации недостатки? Достаточно ли хорошо, чтобы показать, что реализация X быстрее, чем реализация Y по Z итерациям? Можете ли вы придумать, как бы это улучшить?

РЕДАКТИРОВАТЬ Совершенно очевидно, что предпочтительнее использовать подход, основанный на времени (в отличие от итераций), есть ли у кого-нибудь реализации, в которых проверки времени не влияют на производительность?

Сэм Шафран
источник
См. Также BenchmarkDotNet .
Бен Хатчисон,

Ответы:

95

Вот модифицированная функция: как рекомендовано сообществом, не стесняйтесь вносить поправки в это вики сообщества.

static double Profile(string description, int iterations, Action func) {
    //Run at highest priority to minimize fluctuations caused by other processes/threads
    Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High;
    Thread.CurrentThread.Priority = ThreadPriority.Highest;

    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
    return watch.Elapsed.TotalMilliseconds;
}

Убедитесь, что вы компилируете Release с включенной оптимизацией, и запускаете тесты вне Visual Studio . Эта последняя часть важна, потому что JIT ограничивает свою оптимизацию подключенным отладчиком даже в режиме Release.

Sam Saffron
источник
Возможно, вы захотите развернуть цикл некоторое количество раз, например, 10, чтобы минимизировать накладные расходы на цикл.
Mike Dunlavey
2
Я только что обновился, чтобы использовать Stopwatch.StartNew. Не функциональное изменение, но экономия одной строчки кода.
LukeH
1
@ Люк, большие перемены (я бы хотел +1). @ Майк, я не уверен, я подозреваю, что накладные расходы на виртуальный вызов будут намного выше, чем при сравнении и назначении, поэтому разница в производительности будет незначительной
Сэм Саффрон,
Я предлагаю вам передать счетчик итераций Action и создать там цикл (возможно, даже развернутый). Если вы измеряете относительно короткую работу, это единственный вариант. И я бы предпочел видеть обратную метрику - например, количество проходов в секунду.
Alex Yakunin
2
Что вы думаете о показе среднего времени. Примерно так: Console.WriteLine («Среднее время, прошедшее {0} мс», watch.ElapsedMilliseconds / iterations);
rudimenter
22

Доработка не обязательно будет завершена раньше GC.Collect возврата. Завершение ставится в очередь, а затем выполняется в отдельном потоке. Эта ветка может оставаться активной во время ваших тестов, что влияет на результаты.

Если вы хотите убедиться, что финализация завершена перед запуском ваших тестов, вы можете вызвать вызов GC.WaitForPendingFinalizers, который будет заблокирован до тех пор, пока очередь финализации не будет очищена:

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Лука
источник
10
Почему GC.Collect()еще раз?
colinfang
7
@colinfang Потому что "финализируемые" объекты не собираются финализатором. Итак, второй Collectнужен, чтобы убедиться, что «завершенные» объекты также собраны.
MAV
15

Если вы хотите исключить из уравнения взаимодействия с GC, вы можете запустить свой «разогревающий» вызов после вызова GC.Collect, а не до него. Таким образом, вы знаете, что .NET уже будет иметь достаточно памяти, выделенной из ОС для рабочего набора вашей функции.

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

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

Джонатан Рупп
источник
1
хорошие моменты, вы бы имели в виду реализацию на основе времени?
Sam Saffron,
6

Я бы вообще не передавал делегата:

  1. Вызов делегата - это вызов виртуального метода. Недешево: ~ 25% наименьшего объема памяти, выделенного в .NET. Если вас интересуют подробности, см., Например, эту ссылку .
  2. Анонимные делегаты могут привести к использованию закрытий, которые вы даже не заметите. Опять же, доступ к закрывающим полям заметно отличается, например, от доступа к переменной в стеке.

Пример кода, приводящего к использованию закрытия:

public void Test()
{
  int someNumber = 1;
  Profiler.Profile("Closure access", 1000000, 
    () => someNumber + someNumber);
}

Если вы не знаете о замыканиях, взгляните на этот метод в .NET Reflector.

Алексей Якунин
источник
Интересные моменты, но как бы вы создали повторно используемый метод Profile (), если вы не передаете делегата? Есть ли другие способы передать произвольный код методу?
Ash
1
Мы используем "using (новое измерение (...)) {... измеренный код ...}". Таким образом, мы получаем объект Measurement, реализующий IDisposable, вместо передачи делегата. См. Code.google.com/p/dataobjectsdotnet/source/browse/Xtensive.Core/…
Алексей Якунин,
Это не приведет к проблемам с закрытием.
Alex Yakunin
3
@AlexYakunin: похоже, ваша ссылка не работает. Не могли бы вы включить в свой ответ код класса измерений? Я подозреваю, что независимо от того, как вы его реализуете, вы не сможете запускать код для профилирования несколько раз с помощью этого подхода IDisposable. Однако это действительно очень полезно в ситуациях, когда вы хотите измерить, как работают разные части сложного (взаимосвязанного) приложения, при условии, что вы помните, что измерения могут быть неточными и несовместимыми при запуске в разное время. Я использую тот же подход в большинстве своих проектов.
ShdNx
1
Требование запускать тест производительности несколько раз действительно важно (разминка + несколько измерений), поэтому я также перешел на подход с делегатом. Более того, если вы не используете замыкания, вызов делегата выполняется быстрее, чем вызов метода интерфейса в случае с IDisposable.
Алекс Якунин
6

Я думаю, что самая сложная проблема, которую можно решить с помощью таких методов тестирования, - это учет крайних случаев и непредвиденных обстоятельств. Например - «Как два фрагмента кода работают при высокой загрузке ЦП / использовании сети / перегрузке диска и т. Д.». Они отлично подходят для базовой логической проверки, чтобы увидеть, работает ли конкретный алгоритм значительно быстрее, чем другой. Но чтобы правильно протестировать производительность большей части кода, вам нужно будет создать тест, который измеряет конкретные узкие места этого конкретного кода.

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

Пол Александр
источник
1
Значительный - это один из тех терминов, который действительно загружен. иногда важно иметь реализацию, которая на 20% быстрее, иногда она должна быть в 100 раз быстрее, чтобы быть значимой. Согласен с вами по поводу ясности см .: stackoverflow.com/questions/1018407/…
Сэм Саффрон,
В этом случае существенно не все загружено. Вы сравниваете одну или несколько параллельных реализаций, и если разница в производительности этих двух реализаций не является статистически значимой, не стоит использовать более сложный метод.
Paul Alexander
5

Я звонил func()на разминку несколько раз, а не один.

Алексей Романов
источник
1
Намерение состояло в том, чтобы обеспечить выполнение jit-компиляции, какое преимущество вы получаете от многократного вызова func перед измерением?
Сэм Саффрон,
3
Дать JIT шанс улучшить свои первые результаты.
Алексей Романов
1
.NET JIT со временем не улучшает результаты (как это делает Java). Он преобразует метод из IL в Assembly только один раз, при первом вызове.
Мэтт Уоррен
4

Предложения по улучшению

  1. Определение того, подходит ли среда выполнения для тестирования (например, определение того, подключен ли отладчик или отключена ли оптимизация jit, что может привести к неверным измерениям).

  2. Независимое измерение частей кода (чтобы точно увидеть, где находится узкое место).

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

Что касается №1:

  • Чтобы определить, подключен ли отладчик, прочтите свойство System.Diagnostics.Debugger.IsAttached(не забудьте также обработать случай, когда отладчик изначально не подключен, но подключается через некоторое время).

  • Чтобы определить, отключена ли оптимизация jit, прочтите свойство DebuggableAttribute.IsJITOptimizerDisabledсоответствующих сборок:

    private bool IsJitOptimizerDisabled(Assembly assembly)
    {
        return assembly.GetCustomAttributes(typeof (DebuggableAttribute), false)
            .Select(customAttribute => (DebuggableAttribute) customAttribute)
            .Any(attribute => attribute.IsJITOptimizerDisabled);
    }

Что касается №2:

Это можно сделать разными способами. Один из способов - предоставить несколько делегатов, а затем измерить их по отдельности.

Что касается №3:

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

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


Etimo.

Другой подход - использовать существующий компонент для выполнения тестов. Фактически, в моей компании мы решили выпустить наш тестовый инструмент в общественное достояние. По сути, он управляет сборщиком мусора, джиттером, разогревом и т.д., как и предлагают некоторые другие ответы здесь. Он также имеет три функции, которые я предложил выше. Он управляет несколькими проблемами, обсуждаемыми в блоге Эрика Липперта .

Это пример вывода, в котором сравниваются два компонента, и результаты записываются в консоль. В этом случае два сравниваемых компонента называются KeyedCollection и MultiplyIndexedKeyedCollection:

Etimo.Benchmarks - Пример вывода на консоль

Существует пакет NuGet , образец пакета NuGet, а исходный код доступен на GitHub. . Также есть сообщение в блоге .

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

Иоаким
источник
1

Вы также должны выполнить «прогрев» перед фактическим измерением, чтобы исключить время, которое JIT-компилятор тратит на изменение вашего кода.

Алексей Якунин
источник
это выполняется перед измерением
Сэм Сафрон,
1

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

Эдвард Брей
источник
1

Если вы пытаетесь устранить влияние сборки мусора после завершения теста, стоит ли его устанавливать GCSettings.LatencyMode?

Если нет, и вы хотите, чтобы влияние мусора, созданного в, funcбыло частью теста, то не следует ли вам также принудительно выполнять сборку в конце теста (внутри таймера)?

Дэнни Таппени
источник
0

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

Другой ответ дает хороший способ измерить базовую производительность.

static void Profile(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

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

static void ProfileGarbageMany(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

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

static void ProfileGarbage(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();

        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

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

Стивен Стюарт-Галлус
источник