Применение порядка инструкций в C++
Предположим, у меня есть несколько операторов, которые я хочу выполнить в установленный порядок. Я хочу использовать g++ с уровнем оптимизации 2, поэтому некоторые заявления могут быть переупорядочены. Какие инструменты нужно использовать для обеспечения определенного порядка операторов?
рассмотрим следующий пример.
using Clock = std::chrono::high_resolution_clock;
auto t1 = Clock::now(); // Statement 1
foo(); // Statement 2
auto t2 = Clock::now(); // Statement 3
auto elapsedTime = t2 - t1;
в этом примере важно, чтобы операторы 1-3 выполнялись в данный порядок. Однако не может ли компилятор думать, что оператор 2 независимо от 1 и 3 и выполнить код следующим образом?
using Clock=std::chrono::high_resolution_clock;
foo(); // Statement 2
auto t1 = Clock::now(); // Statement 1
auto t2 = Clock::now(); // Statement 3
auto elapsedTime = t2 - t1;
6 ответов:
Я хотел бы попытаться дать несколько более полный ответ после того, как это было обсуждено с Комитетом по стандартам C++. Помимо того, что я являюсь членом комитета C++, я также являюсь разработчиком компиляторов LLVM и Clang.
в принципе, нет никакого способа использовать барьер или какую-либо операцию в последовательности для достижения этих преобразований. Фундаментальная проблема заключается в том, что операционная семантика чего-то вроде целочисленного сложения полностью известно для реализации. Он может имитировать их, он знает, что они не могут наблюдаться правильными программами, и всегда свободен перемещать их.
мы могли бы попытаться предотвратить это, но это будет иметь крайне негативные результаты и в конечном счете терпят неудачу.
во-первых, единственный способ предотвратить это в компиляторе-сказать ему, что все эти основные операции наблюдаемы. Проблема в том, что это тогда исключило бы подавляющее большинство компилятора процессы оптимизации. Внутри компилятора у нас по существу нет хороших механизмов для моделирования того, что времени наблюдаем, но больше ничего. У нас даже нет хорошей модели какие операции требуют времени. Например, требуется ли время для преобразования 32-разрядного целого числа без знака в 64-разрядное целое число без знака? Это занимает нулевое время на x86-64, но на других архитектурах это занимает ненулевое время. Здесь нет общего правильного ответа.
но даже если нам удастся благодаря некоторым героическим действиям по предотвращению переупорядочивания компилятором этих операций, нет никакой гарантии, что этого будет достаточно. Рассмотрим допустимый и соответствующий способ выполнения вашей программы на C++ на машине x86: DynamoRIO. Это система, которая динамически анализирует машинный код программы. Одна вещь, которую он может сделать, - это онлайн-оптимизация, и он даже способен спекулятивно выполнять весь диапазон основных арифметических инструкций вне времени. И это поведение не является уникальным для динамические оценщики, фактический процессор x86 также будет спекулировать (гораздо меньшее количество) инструкций и динамически переупорядочивать их.
существенная реализация заключается в том, что тот факт, что арифметика не наблюдаема (даже на уровне синхронизации) является то, что пронизывает слои компьютера. Это верно для компилятора, среды выполнения, и часто даже оборудование. Принуждение его к наблюдаемости резко ограничило бы компилятор, но это также значительно ограничило бы аппаратура.
но все это не должно заставить вас потерять надежду. Когда вы хотите вовремя выполнить основные математические операции, мы хорошо изучили методы, которые работают надежно. Обычно они используются при выполнении микро-бенчмаркинг. Я говорил об этом на CppCon2015:https://youtu.be/nXaxk27zwlk
методы, показанные там, также предоставляются различными библиотеками микро-бенчмарков, такими как Google: https://github.com/google/benchmark#preventing-optimisation
ключ к этим методам, чтобы сосредоточиться на данных. Вы делаете вход в вычисление непрозрачным для оптимизатора, а результат вычисления непрозрачным для оптимизатора. Как только вы это сделаете, вы можете надежно рассчитать время. Давайте рассмотрим реалистичный вариант примера в исходном вопросе, но с определением
foo
полностью виден для реализации. Я также извлек (непортативная) версияDoNotOptimize
из библиотеки Google Benchmark, которую вы можете найти здесь: https://github.com/google/benchmark/blob/master/include/benchmark/benchmark_api.h#L208#include <chrono> template <class T> __attribute__((always_inline)) inline void DoNotOptimize(const T &value) { asm volatile("" : "+m"(const_cast<T &>(value))); } // The compiler has full knowledge of the implementation. static int foo(int x) { return x * 2; } auto time_foo() { using Clock = std::chrono::high_resolution_clock; auto input = 42; auto t1 = Clock::now(); // Statement 1 DoNotOptimize(input); auto output = foo(input); // Statement 2 DoNotOptimize(output); auto t2 = Clock::now(); // Statement 3 return t2 - t1; }
здесь мы гарантируем, что входные данные и выходные данные отмечены как не оптимизируется по расчету
foo
, и только вокруг этих маркеров вычисляются тайминги. Поскольку вы используете данные для клещей вычисления, он гарантированно останется между ними тайминги и все же само вычисление можно оптимизировать. В результате для платформы x86-64 сборки, созданной на последних сборок с Clang/LLVM с это:% ./bin/clang++ -std=c++14 -c -S -o - so.cpp -O3 .text .file "so.cpp" .globl _Z8time_foov .p2align 4, 0x90 .type _Z8time_foov,@function _Z8time_foov: # @_Z8time_foov .cfi_startproc # BB#0: # %entry pushq %rbx .Ltmp0: .cfi_def_cfa_offset 16 subq , %rsp .Ltmp1: .cfi_def_cfa_offset 32 .Ltmp2: .cfi_offset %rbx, -16 movl , 8(%rsp) callq _ZNSt6chrono3_V212system_clock3nowEv movq %rax, %rbx #APP #NO_APP movl 8(%rsp), %eax addl %eax, %eax # This is "foo"! movl %eax, 12(%rsp) #APP #NO_APP callq _ZNSt6chrono3_V212system_clock3nowEv subq %rbx, %rax addq , %rsp popq %rbx retq .Lfunc_end0: .size _Z8time_foov, .Lfunc_end0-_Z8time_foov .cfi_endproc .ident "clang version 3.9.0 (trunk 273389) (llvm/trunk 273380)" .section ".note.GNU-stack","",@progbits
здесь вы можете увидеть компилятор оптимизирует вызов
foo(input)
вплоть до одной инструкции,addl %eax, %eax
, но не перемещая его за пределы времени или полностью устраняя его, несмотря на постоянный вход.надеюсь, что это поможет, и Комитет по стандартам C++ рассматривает возможность стандартизации API, подобных к
DoNotOptimize
здесь.
резюме:
кажется, нет гарантированного способа предотвратить переупорядочение, но до тех пор, пока не включена оптимизация времени соединения/полной программы,размещение вызываемой функции в отдельном блоке компиляции кажется довольно хорошей ставкой. (По крайней мере, с GCC, хотя логика предполагает, что это, вероятно, и с другими компиляторами тоже.) Это происходит за счет вызова функции-встроенный код по определению находится в той же единице компиляции и открыт для переупорядочивание.
оригинальный ответ:
GCC переупорядочивает вызовы под оптимизацией O2:
#include <chrono> static int foo(int x) // 'static' or not here doesn't affect ordering. { return x*2; } int fred(int x) { auto t1 = std::chrono::high_resolution_clock::now(); int y = foo(x); auto t2 = std::chrono::high_resolution_clock::now(); return y; }
GCC 5.3.0:
g++ -S --std=c++11 -O0 fred.cpp
:_ZL3fooi: pushq %rbp movq %rsp, %rbp movl %ecx, 16(%rbp) movl 16(%rbp), %eax addl %eax, %eax popq %rbp ret _Z4fredi: pushq %rbp movq %rsp, %rbp subq , %rsp movl %ecx, 16(%rbp) call _ZNSt6chrono3_V212system_clock3nowEv movq %rax, -16(%rbp) movl 16(%rbp), %ecx call _ZL3fooi movl %eax, -4(%rbp) call _ZNSt6chrono3_V212system_clock3nowEv movq %rax, -32(%rbp) movl -4(%rbp), %eax addq , %rsp popq %rbp ret
но:
g++ -S --std=c++11 -O2 fred.cpp
:_Z4fredi: pushq %rbx subq , %rsp movl %ecx, %ebx call _ZNSt6chrono3_V212system_clock3nowEv call _ZNSt6chrono3_V212system_clock3nowEv leal (%rbx,%rbx), %eax addq , %rsp popq %rbx ret
теперь, с foo() в качестве функции extern:
#include <chrono> int foo(int x); int fred(int x) { auto t1 = std::chrono::high_resolution_clock::now(); int y = foo(x); auto t2 = std::chrono::high_resolution_clock::now(); return y; }
g++ -S --std=c++11 -O2 fred.cpp
:_Z4fredi: pushq %rbx subq , %rsp movl %ecx, %ebx call _ZNSt6chrono3_V212system_clock3nowEv movl %ebx, %ecx call _Z3fooi movl %eax, %ebx call _ZNSt6chrono3_V212system_clock3nowEv movl %ebx, %eax addq , %rsp popq %rbx ret
но, если это связано с-flto (link-time optimisation):
0000000100401710 <main>: 100401710: 53 push %rbx 100401711: 48 83 ec 20 sub x20,%rsp 100401715: 89 cb mov %ecx,%ebx 100401717: e8 e4 ff ff ff callq 100401700 <__main> 10040171c: e8 bf f9 ff ff callq 1004010e0 <_ZNSt6chrono3_V212system_clock3nowEv> 100401721: e8 ba f9 ff ff callq 1004010e0 <_ZNSt6chrono3_V212system_clock3nowEv> 100401726: 8d 04 1b lea (%rbx,%rbx,1),%eax 100401729: 48 83 c4 20 add x20,%rsp 10040172d: 5b pop %rbx 10040172e: c3 retq
переупорядочивание может выполняться компилятором или процессором.
большинство компиляторов предлагают специфичный для платформы метод для предотвращения переупорядочения инструкций чтения-записи. На gcc, это
asm volatile("" ::: "memory");
(более подробная информация здесь)
обратите внимание, что это только косвенно предотвращает операции переупорядочения, если они зависят от операций чтения / записи.
на практике я еще не видел системы, где система вызова в
Clock::now()
имеет тот же эффект, что такой барьер. Вы можете проверить полученную сборку, чтобы быть уверенным.это не редкость, однако, что тестируемая функция получает оценку во время компиляции. Чтобы обеспечить "реалистичное" выполнение, вам может потребоваться получить входные данные для
foo()
от I / O или avolatile
читать.
другой вариант-отключить встраивание для
foo()
- опять же, это специфично для компилятора и обычно не переносимо, но будет иметь то же самое эффект.на gcc, это было бы
__attribute__ ((noinline))
@Руслан поднимает фундаментальный вопрос: насколько реально это измерение?
время выполнения зависит от многих факторов: один-это фактическое оборудование, на котором мы работаем, другой-параллельный доступ к общим ресурсам, таким как кэш, память, диск и ядра процессора.
Итак, что мы обычно делаем, чтобы получить сравнима тайминги: убедитесь, что они являются воспроизводимость С a низкая погрешность. Это делает их несколько искусственными.
"горячий кэш" против "холодного кэша" производительность выполнения может легко отличаться на порядок - но на самом деле, это будет что-то среднее ("теплое"?)
язык C++ определяет то, что можно наблюдать несколькими способами.
если
foo()
не делает ничего заметного, тогда его можно полностью устранить. Еслиfoo()
только вычисление, которое хранит значения в "локальном" состоянии (будь то в стеке или в объекте где-то), и компилятор может доказать, что ни один безопасно производный указатель не может попасть вClock::now()
код, то нет никаких заметных последствий для перемещенияClock::now()
звонки.если
foo()
взаимодействовал с файлом или дисплей, и компилятор не может доказать, чтоClock::now()
тут не взаимодействие с файлом или дисплеем, то переупорядочение не может быть сделано, потому что взаимодействие с файлом или дисплеем является наблюдаемым поведением.хотя вы можете использовать специфические для компилятора хаки, чтобы заставить код не перемещаться (например, встроенная сборка), другой подход заключается в попытке перехитрить ваш компилятор.
создать динамически загружаемую библиотеку. Загрузить его до рассматриваемого кодекса.
эта библиотека раскрывает одну вещь:
namespace details { void execute( void(*)(void*), void *); }
и обертывает его вот так:
template<class F> void execute( F f ) { struct bundle_t { F f; } bundle = {std::forward<F>(f)}; auto tmp_f = [](void* ptr)->void { auto* pb = static_cast<bundle_t*>(ptr); (pb->f)(); }; details::execute( tmp_f, &bundle ); }
который упаковывает нулевую лямбду и использует динамическую библиотеку для ее запуска в контексте, который компилятор не может понять.
внутри динамической библиотеки, мы делаем:
void details::execute( void(*f)(void*), void *p) { f(p); }
что довольно просто.
теперь, чтобы изменить порядок вызовов
execute
, он должен понимать динамическую библиотеку, которую он не удается при компиляции тестового кода.он все еще может ликвидировать
foo()
s с нулевыми побочными эффектами, но вы выигрываете некоторые, вы теряете некоторые.
нет, этого не может. Согласно стандарту C++ [интро.исполнение]:
14 каждое вычисление значения и побочный эффект, связанный с полное выражение применяется перед каждым вычислением стоимости и побочных эффект, связанный со следующего полного выражения.
полное выражение-это в основном оператор, заканчивающийся точкой с запятой. Как вы можете видеть, приведенное выше правило предусматривает, что операторы должны выполняться по порядку. Это внутри заявления о том, что компилятору разрешено более свободно управлять (т. е. при некоторых обстоятельствах разрешено оценивать выражения, которые составляют оператор в порядках, отличных от слева направо или чего-либо еще конкретного).
обратите внимание, что условия для применения правила as-if здесь не выполняются. Неразумно думать, что любой компилятор сможет доказать что переупорядочивание вызовов для получения системного времени не повлияет на наблюдаемое поведение программы. Если было обстоятельство, в котором два вызова для получения времени могут быть переупорядочены без изменения наблюдаемого поведения, было бы крайне неэффективно фактически создавать компилятор, который анализирует программу с достаточным пониманием, чтобы быть в состоянии сделать это с уверенностью.
нет.
иногда, по правилу "как-будто", операторы могут быть переупорядочены. Это не потому, что они логически независимы друг от друга, а потому, что эта независимость позволяет такие реорганизации происходят без изменения семантики программы.
перемещение системного вызова, который получает текущее время, очевидно, не удовлетворяет этому условию. Компилятор, который сознательно или неосознанно делает это, является несовместимым и действительно глупый.
В общем, я бы не ожидал, что какое-либо выражение, которое приводит к системному вызову, будет "угадано" даже агрессивно оптимизирующим компилятором. Он просто не знает достаточно о том, что делает этот системный вызов.