Я решил, что хочу тестировать определенную функцию, поэтому наивно пишу такой код:
#include <ctime>
#include <iostream>
int SlowCalculation(int input) { ... }
int main() {
std::cout << "Benchmark running..." << std::endl;
std::clock_t start = std::clock();
int answer = SlowCalculation(42);
std::clock_t stop = std::clock();
double delta = (stop - start) * 1.0 / CLOCKS_PER_SEC;
std::cout << "Benchmark took " << delta << " seconds, and the answer was "<< answer << '.' << std::endl;
return 0;
}
Коллега указал, что я должен объявить start
а также stop
переменные как volatile
чтобы избежать переупорядочения кода. Он предположил, что оптимизатор может, например, эффективно изменить порядок кода следующим образом:
std::clock_t start = std::clock();
std::clock_t stop = std::clock();
int answer = SlowCalculation(42);
Сначала я скептически относился к тому, что такое экстремальное изменение порядка было разрешено, но после некоторых исследований и экспериментов я узнал, что это так.
Но изменчивый не чувствовал себя как правильное решение; не является энергозависимым на самом деле только для ввода-вывода с отображением памяти?
Тем не менее я добавил volatile
и обнаружил, что тест не только занимал значительно больше времени, но и был крайне непоследователен от бега к бегу. Без изменчивости (и не повезло, что код не был переупорядочен), эталонный тест постоянно занимал 600-700 мс. Для энергозависимых это часто занимало 1200 мс, а иногда и более 5000 мс. Списки разборки для двух версий не показали практически никакой разницы, кроме различного выбора регистров. Это заставляет меня задуматься, есть ли другой способ избежать переупорядочения кода, у которого нет таких чрезмерных побочных эффектов.
Мой вопрос:
Каков наилучший способ предотвратить переупорядочение кода в подобном коде?
Мой вопрос похож на этот (который был об использовании volatile, чтобы избежать выбытия, а не переупорядочения), этот (который не ответил, как предотвратить переупорядочение), и этот (который обсуждал, была ли проблема переупорядочивание кода или устранение мертвого кода). Хотя все трое посвящены именно этой теме, никто не отвечает на мой вопрос.
ОбновитьОтвет, похоже, заключается в том, что мой коллега ошибся и что такое повторное упорядочение не соответствует стандарту. Я проголосовал за всех, кто так сказал, и присуждаю награду Максиму.
Я видел один случай (на основе кода в этот вопрос) где Visual Studio 2010 переупорядочил вызовы часов, как я иллюстрировал (только в 64-битных сборках). Я пытаюсь сделать минимальный пример, чтобы проиллюстрировать это, чтобы я мог сообщить об ошибке в Microsoft Connect.
Для тех, кто сказал, что volatile должно быть намного медленнее, потому что оно вызывает чтение и запись в память, это не совсем соответствует испускаемому коду. В моем ответе на этот вопрос, Я показываю разборку для кода с и без volatile. Внутри цикла все хранится в регистрах. Единственными существенными отличиями являются выбор регистров. Я не достаточно хорошо понимаю сборку x86, чтобы понять, почему производительность энергонезависимой версии последовательно быстро, в то время как летучая версия непоследовательно (а иногда и резко) медленнее.
Коллега отметил, что я должен объявить переменные start и stop как volatile, чтобы избежать переупорядочения кода.
Извините, но ваш коллега не прав.
Компилятор не переупорядочивает вызовы функций, определения которых недоступны во время компиляции. Просто представьте веселье, которое произойдет, если компилятор переупорядочит такие вызовы как fork
а также exec
или переместить код вокруг них.
Другими словами, любая функция без определения является барьером памяти времени компиляции, то есть компилятор не перемещает последующие операторы до вызова или предыдущие операторы после вызова.
В вашем коде звонки std::clock
в конечном итоге вызов функции, определение которой недоступно.
Я не могу рекомендовать достаточно смотреть атомное оружие: модель памяти C ++ и современное оборудование потому что он обсуждает неправильные представления о (время компиляции) барьеров памяти и volatile
среди многих других полезных вещей.
Тем не менее, я добавил volatile и обнаружил, что тест не только занимал значительно больше времени, но и был крайне непоследователен от запуска к запуску. Без изменчивости (и не повезло, что код не был переупорядочен), эталонный тест постоянно занимал 600-700 мс. С энергозависимым, это часто занимало 1200 мс, а иногда и более 5000 мс
Не уверен если volatile
здесь виноват.
Время выполнения отчета зависит от того, как выполняется тест. Убедитесь, что вы отключили масштабирование частоты процессора, чтобы он не включал турбо-режим или не переключал частоту в середине цикла. Кроме того, микропроцессоры должны выполняться как приоритетные процессы в реальном времени, чтобы избежать планирования шума. Может случиться так, что во время другого запуска некоторый фоновый индексатор файлов начнет конкурировать с вашим эталоном за время процессора. Увидеть этот Больше подробностей.
Хорошая практика — измерять время, необходимое для выполнения функции несколько раз, и сообщать минимальные / средние / средние / максимальные / stdev / общие значения времени. Высокое стандартное отклонение может указывать на то, что вышеуказанные препараты не выполняются. Первый запуск часто длится дольше, потому что кэш-память ЦП может быть холодной, и он может принимать много ошибок в кеше и сбоях страниц, а также разрешать динамические символы из общих библиотек при первом вызове (ленивое разрешение символов является режимом связывания по умолчанию во время выполнения в Linux , например), в то время как последующие вызовы будут выполняться с гораздо меньшими накладными расходами.
Вы можете сделать два файла C, SlowCalculation
составлено с g++ -O3
(высокий уровень оптимизации) и эталонный тест, скомпилированный с g++ -O1
(более низкий уровень, все еще оптимизированный — этого может быть достаточно для этой части бенчмаркинга).
Согласно справочная страница, переупорядочение кода происходит во время -O2
а также -O3
уровни оптимизации.
Поскольку оптимизация происходит во время компиляции, а не компоновки, переупорядочение кода не должно влиять на тестовую сторону.
Предполагая, что вы используете g++
— но должно быть что-то эквивалентное в другом компиляторе.
Обычный способ предотвратить переупорядочение — это барьер компиляции, т.е. asm volatile ("":::"memory");
(с gcc). Это asm-инструкция, которая ничего не делает, но мы сообщаем компилятору, что она захлопнет память, поэтому не разрешается переупорядочивать код через нее. Стоимость этого является только фактической стоимостью удаления повторного заказа, что, очевидно, не относится к изменению уровня оптимизации и т. Д., Как предлагается в другом месте.
я верю _ReadWriteBarrier
эквивалентно для вещей Microsoft.
Согласно ответу Максима Егорушкина, изменение порядка вряд ли станет причиной ваших проблем.
Правильный способ сделать это в C ++ — это использовать учебный класс, например что-то вроде
class Timer
{
std::clock_t startTime;
std::clock_t* targetTime;
public:
Timer(std::clock_t* target) : targetTime(target) { startTime = std::clock(); }
~Timer() { *target = std::clock() - startTime; }
};
и используйте это так:
std::clock_t slowTime;
{
Timer timer(&slowTime);
int answer = SlowCalculation(42);
}
Имейте в виду, я на самом деле не верю, что ваш компилятор когда-либо будет переупорядочивать таким образом.
Volatile обеспечивает одну вещь и только одну: чтение из переменной volatile будет считываться из памяти каждый раз — компилятор не будет предполагать, что значение может быть кэшировано в регистре. И точно так же, записи будут записаны в память. Компилятор не будет хранить его в регистре «некоторое время, прежде чем записать его в память».
Чтобы предотвратить переупорядочивание компилятора, вы можете использовать так называемые ограждения компилятора.
MSVC включает в себя 3 ограждения компилятора:
_ReadWriteBarrier () — полный забор
_ReadBarrier () — двусторонний забор для грузов
_WriteBarrier () — двусторонний забор для магазинов
ICC включает в себя __memory_barrier () полный забор.
Полные заборы обычно являются лучшим выбором, потому что нет необходимости в более тонкой детализации на этом уровне (заборы компилятора в основном не требуют затрат во время выполнения).
Изменение порядка статов (что делает большинство компиляторов, когда включена оптимизация), это также основная причина, по которой некоторые программы не работают, когда компилируются с оптимизацией компилятора.
Предложит прочитать http://preshing.com/20120625/memory-ordering-at-compile-time чтобы увидеть потенциальные проблемы, с которыми мы можем столкнуться при переупорядочении компилятора и т. д.
Есть несколько способов, которые я могу придумать. Идея состоит в том, чтобы создать временные барьеры компиляции, чтобы компилятор не переупорядочивал набор инструкций.
Одним из возможных способов избежать переупорядочения было бы обеспечение зависимости между инструкциями, которые не могут быть разрешены компилятором (например, передача указателя на функцию и использование этого указателя в более поздней инструкции). Я не уверен, как это повлияет на производительность реального кода, который вы заинтересованы в бенчмаркинге.
Другая возможность состоит в том, чтобы сделать функцию SlowCalculation(42);
extern
функция (определите эту функцию в отдельном файле .c / .cpp и свяжите файл с вашей основной программой) и объявите start
а также stop
как глобальные переменные. Я не знаю, каковы оптимизации, предлагаемые оптимизатором времени компоновки / компоновки.
Кроме того, если вы компилируете в O1 или O0, скорее всего, компилятор не будет беспокоиться о переупорядочении команд.
Ваш вопрос несколько связан с (Барьеры времени компиляции — переупорядочение кода компилятора — gcc и pthreads)
Переупорядочение, описанное вашим коллегой, просто ломается 1.9 / 13
Последовательность перед — это асимметричное, транзитивное, попарное отношение между оценками, выполненными одним
поток (1.10), который вызывает частичный порядок среди этих оценок. Учитывая любые две оценки A и B, если
Последовательность A предшествует B, тогда выполнение A должно предшествовать выполнению B. Если A не упорядочено до
B и B не секвенируются до A, тогда A и B не секвенируются. [Примечание: выполнение непоследовательного
оценки могут совпадать. — конец примечания] Оценки A и B имеют неопределенную последовательность, когда либо A
секвенируется до того, как B или B секвенируется до A, но не указано, какой именно. [Примечание: неопределенно
последовательные оценки не могут перекрываться, но любая из них может быть выполнена первой. —Конечная записка]
Так что в основном вам не следует думать о переупорядочении, пока вы не используете потоки.