Применение порядка инструкций в 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 90

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 или a volatile читать.


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

нет.

иногда, по правилу "как-будто", операторы могут быть переупорядочены. Это не потому, что они логически независимы друг от друга, а потому, что эта независимость позволяет такие реорганизации происходят без изменения семантики программы.

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

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