Подтвердить что ты не робот

Является ли мой метод измерения времени работы испорченным?

Извините, это длинный, но я просто объясняю свой ход мысли, когда я анализирую это. Вопросы в конце.

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

В попытке измерить время выполнения для кого-то, я придумал этот код после нескольких ревизий.

В конце концов я закончил с этим кодом, который дал результаты, которые я собирался захватить, не вводя в заблуждение цифры:

// implementation C
static void Test<T>(string testName, Func<T> test, int iterations = 1000000)
{
    Console.WriteLine(testName);
    Console.WriteLine("Iterations: {0}", iterations);
    var results = Enumerable.Repeat(0, iterations).Select(i => new System.Diagnostics.Stopwatch()).ToList();
    var timer = System.Diagnostics.Stopwatch.StartNew();
    for (int i = 0; i < results.Count; i++)
    {
        results[i].Start();
        test();
        results[i].Stop();
    }
    timer.Stop();
    Console.WriteLine("Time(ms): {0,3}/{1,10}/{2,8} ({3,10})", results.Min(t => t.ElapsedMilliseconds), results.Average(t => t.ElapsedMilliseconds), results.Max(t => t.ElapsedMilliseconds), timer.ElapsedMilliseconds);
    Console.WriteLine("Ticks:    {0,3}/{1,10}/{2,8} ({3,10})", results.Min(t => t.ElapsedTicks), results.Average(t => t.ElapsedTicks), results.Max(t => t.ElapsedTicks), timer.ElapsedTicks);
    Console.WriteLine();
}

Из всего кода, который я видел, что время выполнения мер, они обычно были в форме:

// approach 1 pseudocode
start timer;
loop N times:
    run testing code (directly or via function);
stop timer;
report results;

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

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

// approach 2 pseudocode
loop N times:
    start timer;
    run testing code (directly or via function);
    stop timer;
    store results;
report results;

Это хорошо, потому что я мог бы найти минимальное, максимальное и среднее время, числа, которые меня интересовали. До сих пор я понял, что это может потенциально исказить результаты, поскольку кэш потенциально может быть затронут, поскольку цикл wasn ' t очень плотно, давая мне менее оптимальные результаты.


То, как я написал тестовый код (используя LINQ), добавило дополнительные накладные расходы, о которых я знал, но проигнорировал, так как я просто измерял текущий код, а не накладные расходы. Вот моя первая версия:

// implementation A
static void Test<T>(string testName, Func<T> test, int iterations = 1000000)
{
    Console.WriteLine(testName);
    var results = Enumerable.Repeat(0, iterations).Select(i =>
    {
        var timer = System.Diagnostics.Stopwatch.StartNew();
        test();
        timer.Stop();
        return timer;
    }).ToList();
    Console.WriteLine("Time(ms): {0,3}/{1,10}/{2,8}", results.Min(t => t.ElapsedMilliseconds), results.Average(t => t.ElapsedMilliseconds), results.Max(t => t.ElapsedMilliseconds));
    Console.WriteLine("Ticks:    {0,3}/{1,10}/{2,8}", results.Min(t => t.ElapsedTicks), results.Average(t => t.ElapsedTicks), results.Max(t => t.ElapsedTicks));
    Console.WriteLine();
}

Здесь я думал, что все в порядке, так как я измеряю только время, необходимое для запуска тестовой функции. Накладные расходы, связанные с LINQ, не включаются в время работы. Чтобы уменьшить накладные расходы на создание объектов таймера в цикле, я сделал модификацию.

// implementation B
static void Test<T>(string testName, Func<T> test, int iterations = 1000000)
{
    Console.WriteLine(testName);
    Console.WriteLine("Iterations: {0}", iterations);
    var results = Enumerable.Repeat(0, iterations).Select(i => new System.Diagnostics.Stopwatch()).ToList();
    results.ForEach(t =>
    {
        t.Start();
        test();
        t.Stop();
    });
    Console.WriteLine("Time(ms): {0,3}/{1,10}/{2,8} ({3,10})", results.Min(t => t.ElapsedMilliseconds), results.Average(t => t.ElapsedMilliseconds), results.Max(t => t.ElapsedMilliseconds), results.Sum(t => t.ElapsedMilliseconds));
    Console.WriteLine("Ticks:    {0,3}/{1,10}/{2,8} ({3,10})", results.Min(t => t.ElapsedTicks), results.Average(t => t.ElapsedTicks), results.Max(t => t.ElapsedTicks), results.Sum(t => t.ElapsedTicks));
    Console.WriteLine();
}

Это улучшило общее время, но вызвало небольшую проблему. Я добавил общее время работы в отчете, добавив каждое время итерации, но дал вводящие в заблуждение цифры, так как времена были короткими и не отражали фактическое время работы (что обычно было намного дольше). Мне нужно было измерить время всего цикла, поэтому я отошел от LINQ и получил код, который у меня сейчас наверху. Этот гибрид получает время, которое, как мне кажется, важно при минимальных накладных расходах AFAIK. (запуск и остановка таймера задает только таймер с высоким разрешением). Также любое изменение контекста для меня не имеет значения, поскольку оно является частью нормального выполнения в любом случае.

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


Теперь мои вопросы:

  • Я сделал правильный выбор? Некоторые неправильные?
  • Я сделал неправильные предположения о целях в процессе моей мысли?
  • Будет ли минимальное или максимальное время работы действительно полезной информацией, чтобы иметь или это потерянное дело?
  • Если да, то какой подход будет лучше вообще? Время работы в цикле (подход 1)? Или время работает только на рассматриваемом коде (подход 2)?
  • Можно ли использовать мой гибридный подход в целом?
  • Должен ли я уступить (по причинам, указанным в последнем абзаце), или это больше вреда для времени, чем необходимо?
  • Есть ли более предпочтительный способ сделать это, о котором я не упоминал?

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

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

// final implementation
static void Test<T>(string testName, Func<T> test, int iterations = 1000000)
{
    // print header
    var results = Enumerable.Repeat(0, iterations).Select(i => new System.Diagnostics.Stopwatch()).ToList();
    for (int i = 0; i < 100; i++) // warm up the cache
    {
        test();
    }
    var timer = System.Diagnostics.Stopwatch.StartNew(); // time whole process
    for (int i = 0; i < results.Count; i++)
    {
        results[i].Start(); // time individual process
        test();
        results[i].Stop();
    }
    timer.Stop();
    // report results
}

Для щедрости, я бы идеально хотел, чтобы на все вышеперечисленные вопросы ответили. Я надеюсь на хорошее объяснение того, что мои мысли, которые повлияли на код здесь, были оправданы (и, возможно, мысли о том, как улучшить его, если они субоптимальны), или если я ошибался с точкой, объясните, почему это неправильно и/или не нужно, и если применимы, предлагают лучшую альтернативу.

Подводя итог важным вопросам и моим мыслям о принятых решениях:

  • Получает ли время работы каждой отдельной итерации вообще хорошее дело?
    Со временем для каждой отдельной итерации я могу рассчитать дополнительную статистическую информацию, такую ​​как минимальное и максимальное время работы, а также стандартное отклонение. Поэтому я вижу, есть ли такие факторы, как кеширование или другие неизвестные, могут искажать результаты. Это приводит к моей "гибридной" версии.
  • Имеет небольшой цикл прогонов до того, как фактическое время начала тоже хорошо?
    Из моего ответа на Сам Шаффрон подумал о цикле, это увеличивает вероятность того, что постоянно доступ к памяти будет кэшироваться. Таким образом, я измеряю время только тогда, когда все кэшируется, а не в некоторых случаях, когда доступ к памяти не кэшируется.
  • Помогло ли принудительное Thread.Yield() в цикле или повредило тайминги тестовых случаев, связанных с CPU?
    Если процесс был связан с ЦП, планировщик ОС снизил бы приоритет этой задачи, увеличивая время из-за нехватки времени на ЦП. Если он не связан с ЦП, я бы опустил урожай.

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

4b9b3361

Ответ 1

Моя первая мысль состоит в том, что цикл такой же простой, как

for (int i = 0; i < x; i++)
{
    timer.Start();
    test();
    timer.Stop();
}

выглядит глупо по сравнению с:

timer.Start();
for (int i = 0; i < x; i++)
    test();
timer.Stop();

причина в том, что (1) этот тип цикла "для" имеет очень малые накладные расходы, настолько малые, что почти не стоит беспокоиться, даже если test() занимает только микросекунду и (2) timer.Start( ) и timer.Stop() имеют свои собственные служебные данные, которые могут повлиять на результаты больше, чем цикл for. Тем не менее, я заглянул в Секундомер в Reflector и заметил, что Start() и Stop() довольно дешевы (вызов свойств Elapsed *, скорее всего, будет более дорогостоящим, учитывая математику).

Убедитесь, что свойство IsHighResolution для Секундомера истинно. Если он ошибочен, секундомер использует DateTime.UtcNow, который, я считаю, обновляется только каждые 15-16 мс.

1. Получает ли время работы каждой отдельной итерации вообще хорошее дело?

Обычно не требуется измерять время выполнения каждой отдельной итерации, но полезно узнать, насколько производительность зависит от разных итераций. С этой целью вы можете вычислить min/max (или k outliers) и стандартное отклонение. Только "медианная" статистика требует, чтобы вы записывали каждую итерацию.

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

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

Для себя я написал небольшой класс для сбора min, max, mean и стандартного отклонения, который можно использовать для тестов или других статистических данных:

// A lightweight class to help you compute the minimum, maximum, average
// and standard deviation of a set of values. Call Clear(), then Add(each
// value); you can compute the average and standard deviation at any time by 
// calling Avg() and StdDeviation().
class Statistic
{
    public double Min;
    public double Max;
    public double Count;
    public double SumTotal;
    public double SumOfSquares;

    public void Clear()
    {
        SumOfSquares = Min = Max = Count = SumTotal = 0;
    }
    public void Add(double nextValue)
    {
        Debug.Assert(!double.IsNaN(nextValue));
        if (Count > 0)
        {
            if (Min > nextValue)
                Min = nextValue;
            if (Max < nextValue)
                Max = nextValue;
            SumTotal += nextValue;
            SumOfSquares += nextValue * nextValue;
            Count++;
        }
        else
        {
            Min = Max = SumTotal = nextValue;
            SumOfSquares = nextValue * nextValue;
            Count = 1;
        }
    }
    public double Avg()
    {
        return SumTotal / Count;
    }
    public double Variance()
    {
        return (SumOfSquares * Count - SumTotal * SumTotal) / (Count * (Count - 1));
    }
    public double StdDeviation()
    {
        return Math.Sqrt(Variance());
    }
    public Statistic Clone()
    {
        return (Statistic)MemberwiseClone();
    }
};

2. Имеет ли малый цикл пробегов до того, как фактическое время тоже начнет хорошо?

Какие итерации, которые вы измеряете, зависят от того, насколько вы больше всего заботитесь о времени запуска, установившемся времени или общей продолжительности работы. В общем, может быть полезно записать один или несколько прогонов отдельно, поскольку запускается запуск. Вы можете ожидать, что первая итерация (а иногда и несколько) будет работать медленнее. В качестве крайнего примера, моя библиотека GoInterfaces последовательно занимает около 140 миллисекунд, чтобы создать свой первый выход, а затем еще 9 примерно за 15 мс.

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

3. Будет ли принудительный Thread.Yield() в цикле помочь или повредить тайминги тестовых случаев с привязкой к процессору?

Я не уверен. Он может очистить кэши процессора (L1, L2, TLB), что не только замедлит ваш тест в целом, но и уменьшит измеренные скорости. Ваши результаты будут более "искусственными", не отражающими также то, что вы получите в реальном мире. Возможно, лучший подход - избегать запуска других задач одновременно с вашим эталоном.

Ответ 2

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

Просто позвоните в свой тестовый код с пустым Func<T> первым, то есть

void EmptyFunc<T>() {}

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

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

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

  • сравнивает пустую функцию
  • вычислить средние накладные расходы из результата
  • проверить реальную тестовую функцию
  • вычесть средние издержки по результатам тестирования
  • Вы закончили

Таким образом, фактический механизм синхронизации становится намного менее важным.

Ответ 3

Я думаю, что ваш первый пример кода кажется лучшим подходом.

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

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

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

НТН.

Ответ 4

Я склонен согласиться с @Самом Шаффроном об использовании одного секундомера, а не одного за итерацию. В вашем примере вы выполняете 1000000 итераций по умолчанию. Я не знаю, какова стоимость создания одного секундомера, но вы создаете 1000000 из них. Разумеется, это само по себе может повлиять на результаты теста. Я немного переработал вашу "окончательную реализацию", чтобы позволить измерение каждой итерации без создания 1000000 секундомеров. Конечно, поскольку я сохраняю результат каждой итерации, я выделяю 1000000 longs, но на первый взгляд кажется, что это будет иметь менее общий эффект, чем выделение многих секундомеров. Я не сравнивал свою версию с вашей версией, чтобы увидеть, смогут ли мои результаты получить разные результаты.

static void Test2<T>(string testName, Func<T> test, int iterations = 1000000)
{
  long [] results = new long [iterations];

  // print header 
  for (int i = 0; i < 100; i++) // warm up the cache 
  {
    test();
  }

  var timer = System.Diagnostics.Stopwatch.StartNew(); // time whole process 

  long start;

  for (int i = 0; i < results.Length; i++)
  {
    start = Stopwatch.GetTimestamp();
    test();
    results[i] = Stopwatch.GetTimestamp() - start;
  }

  timer.Stop();

  double ticksPerMillisecond = Stopwatch.Frequency / 1000.0;

  Console.WriteLine("Time(ms): {0,3}/{1,10}/{2,8} ({3,10})", results.Min(t => t / ticksPerMillisecond), results.Average(t => t / ticksPerMillisecond), results.Max(t => t / ticksPerMillisecond), results.Sum(t => t / ticksPerMillisecond));
  Console.WriteLine("Ticks:    {0,3}/{1,10}/{2,8} ({3,10})", results.Min(), results.Average(), results.Max(), results.Sum());

  Console.WriteLine();
}

Я использую статический метод GetTimestamp Секундомера дважды на каждой итерации. Дельта между будет время, затраченное на итерацию. Используя Stopwatch.Frequency, мы можем преобразовать значения дельта в миллисекунды.

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

Я не знаю, что моя идея лучше или хуже вашей, но она немного отличается: -)

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

Существует приемлемая точка, в которой сохранение каждого отдельного временного результата является контрпродуктивным из-за стоимости хранения, необходимой для хранения всего массива значений (или таймеров). За меньшую память, но больше времени обработки, вы можете просто суммировать дельтах, вычисляя минимальные и максимальные значения по мере продвижения. Это потенциально может отбросить ваши результаты, но если вы в первую очередь занимаетесь статистикой, созданной на основе инвазивных измерений итерации, то вы можете просто выполнить вычисление min и max за пределами проверки дельта-времени:

static void Test2<T>(string testName, Func<T> test, int iterations = 1000000)
{
  //long [] results = new long [iterations];
  long min = long.MaxValue;
  long max = long.MinValue;

  // print header 
  for (int i = 0; i < 100; i++) // warm up the cache 
  {
    test();
  }

  var timer = System.Diagnostics.Stopwatch.StartNew(); // time whole process 

  long start;
  long delta;
  long sum = 0;

  for (int i = 0; i < iterations; i++)
  {
    start = Stopwatch.GetTimestamp();
    test();
    delta = Stopwatch.GetTimestamp() - start;
    if (delta < min) min = delta;
    if (delta > max) max = delta;
    sum += delta;
  }

  timer.Stop();

  double ticksPerMillisecond = Stopwatch.Frequency / 1000.0;

  Console.WriteLine("Time(ms): {0,3}/{1,10}/{2,8} ({3,10})", min / ticksPerMillisecond, sum / ticksPerMillisecond / iterations, max / ticksPerMillisecond, sum);
  Console.WriteLine("Ticks:    {0,3}/{1,10}/{2,8} ({3,10})", min, sum / iterations, max, sum);

  Console.WriteLine();
}

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

Ответ 5

Логика в подходе 2 чувствует себя "более грубой" для меня, но я всего лишь студент CS.

Я наткнулся на эту ссылку, которая может вас заинтересовать: http://www.yoda.arachsys.com/csharp/benchmark.html

Ответ 6

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

Ответ 7

У меня был аналогичный вопрос здесь.

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

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

Ответ 8

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

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

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

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

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