Сравнивая небольшие образцы кода на С#, можно ли улучшить эту реализацию?

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

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

Ответ 1

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

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.

Ответ 2

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

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

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

Ответ 3

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

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

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

Ответ 4

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

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

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

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

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

Ответ 5

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

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

Ответ 6

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

Ответ 7

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

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

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

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

Относительно # 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.Benchmarks

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

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

Etimo.Benchmarks - Sample Console Output

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

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

Ответ 8

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

Ответ 9

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

Ответ 10

Если вы пытаетесь устранить влияние коллекции Garbage Collection из теста, стоит ли устанавливать GCSettings.LatencyMode?

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

Ответ 11

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

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

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

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