Обеспечение порядка операторов в 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

методы, показанные там, также предоставляются различными библиотеками micro-benchmark, такими как Google: https://github.com/google/benchmark#preventing-optimisation

ключ к этим методам-сосредоточиться на данных. Вы делаете вход в вычисление непрозрачным для оптимизатора, а результат вычисления непрозрачным для оптимизатора. Как только вы это сделаете, вы сможете точно рассчитать время. Давайте рассмотрим реалистичный вариант примера в исходном вопросе, но с определением foo полностью виден для реализации. Я также извлек (non-portable) версия 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, хотя логика предполагает, что это вероятно и с другими компиляторами.) Это происходит за счет функции call-inlined код по определению находится в том же блоке компиляции и открыт для переупорядочивание.

оригинальный ответ:

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 (оптимизация времени связи):

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() от ввода / вывода или volatile читать.


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

на gcc это будет __attribute__ ((noinline))


@Ruslan поднимает фундаментальный вопрос: насколько реалистично это измерение?

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

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


нет.

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

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

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