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


довольно часто на 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 итераций? Можете ли вы придумать какие-либо способы улучшить это?

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

11 100

11 ответов:

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

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;
}

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

финализация не обязательно будет завершена до GC.Collect возвращает. Финализация ставится в очередь, а затем выполняется в отдельном потоке. Этот поток все еще может быть активен во время тестов, что влияет на результаты.

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

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

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

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

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

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

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

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

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

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

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

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

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

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

  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 - Sample Console Output

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

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

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

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

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

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

основная проблема с вашим вопросом заключается в предположении, что один измерение может ответить на все ваши вопросы. Вам нужно измерить несколько раз, чтобы получить эффективную картину ситуации и особенно в мусоре, собранном langauge, как 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);
}

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