Мой метод измерения времени работы некорректен? - PullRequest
16 голосов
/ 23 октября 2010

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

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

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

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

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

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


То, как я написал тестовый код (с использованием 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
}

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

Суммируя важные вопросы и мои мысли о принятых решениях:

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

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

Ответы [ 8 ]

8 голосов
/ 09 ноября 2010

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

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

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

1. Хорошо ли иметь время выполнения каждой отдельной итерации?

Обычно нет необходимости измерять время выполнения каждой отдельной итерации, но полезно , чтобы выяснить, насколько производительность варьируется между различными итерациями. Для этого вы можете вычислить минимальное / максимальное (или k выбросов) и стандартное отклонение. Только «медианная» статистика требует, чтобы вы записывали каждую итерацию.

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

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

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

// 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), что не только замедлит ваш тест в целом, но и снизит измеренные скорости. Ваши результаты будут более «искусственными», а не отражающими то, что вы получили бы в реальном мире. Возможно, лучшим подходом будет избегать выполнения других задач одновременно с тестом.

4 голосов
/ 14 ноября 2010

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

Сначала просто назовите свой код сравнения с пустым Func<T>, т.е.

void EmptyFunc<T>() {}

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

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

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

  1. бенчмарк для пустой функции
  2. рассчитать средние накладные расходы из результата
  3. бенчмарк реальная тест-функция
  4. вычесть средние накладные расходы из этих результатов теста
  5. все готово

При этом фактический механизм синхронизации внезапно становится намного менее важным.

2 голосов
/ 23 октября 2010

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

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

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

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

HTH.

1 голос
/ 09 ноября 2010

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

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, мы можем преобразовать значения дельты в миллисекунды.

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

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

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

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

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, но все равно выполняет свою работу.

0 голосов
/ 14 ноября 2010

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

Однако необходимо учитывать, является ли влияниеПропуски кэша ЦП - это на самом деле справедливая попытка противостоять?

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

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

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

0 голосов
/ 05 ноября 2010

У меня был похожий вопрос здесь .

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

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

0 голосов
/ 23 октября 2010

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

0 голосов
/ 23 октября 2010

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

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

...