Выполнение порядка операторов в С++

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

Ответ 1

Я хотел бы попытаться дать несколько более полный ответ после того, как это было обсуждено с комитетом по стандартам С++. Помимо того, что я являюсь членом комитета С++, я также являюсь разработчиком компиляторов LLVM и Clang.

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

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

Во-первых, единственный способ предотвратить это в компиляторе - сказать, что все эти основные операции наблюдаемы. Проблема в том, что это тогда исключало бы подавляющее большинство оптимизаций компилятора. Внутри компилятора у нас практически нет хороших механизмов для моделирования того, что время наблюдает, но больше ничего. У нас даже нет хорошей модели того, какие операции требуют времени. Например, преобразует ли 32-разрядное целое число без знака в 64-разрядное целое число без знака? Требуется нулевое время на x86-64, но на других архитектурах требуется ненулевое время. Здесь нет общего правильного ответа.

Но даже если нам удастся с помощью некоторых героев предотвратить компилятор от переупорядочения этих операций, нет никакой гарантии, что этого будет достаточно. Рассмотрим действительный и соответствующий способ выполнения вашей С++-программы на машине 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    $16, %rsp
.Ltmp1:
        .cfi_def_cfa_offset 32
.Ltmp2:
        .cfi_offset %rbx, -16
        movl    $42, 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    $16, %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, но не перемещая его за пределы времени или полностью исключая его, несмотря на постоянный ввод.

Надеюсь, что это поможет, и комитет по стандартам С++ рассмотрит возможность стандартизации API, аналогичных DoNotOptimize здесь.

Ответ 2

Резюме:

Кажется, нет гарантированного способа предотвратить переупорядочение, но пока оптимизация link-time/full-program не включена, поиск вызываемой функции в отдельном компиляторе кажется довольно хорошей ставкой. (По крайней мере, с 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    $64, %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    $64, %rsp
        popq    %rbp
        ret

Но:

g++ -S --std=c++11 -O2 fred.cpp:

_Z4fredi:
        pushq   %rbx
        subq    $32, %rsp
        movl    %ecx, %ebx
        call    _ZNSt6chrono3_V212system_clock3nowEv
        call    _ZNSt6chrono3_V212system_clock3nowEv
        leal    (%rbx,%rbx), %eax
        addq    $32, %rsp
        popq    %rbx
        ret

Теперь, когда foo() является внешней функцией:

#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    $32, %rsp
        movl    %ecx, %ebx
        call    _ZNSt6chrono3_V212system_clock3nowEv
        movl    %ebx, %ecx
        call    _Z3fooi
        movl    %eax, %ebx
        call    _ZNSt6chrono3_V212system_clock3nowEv
        movl    %ebx, %eax
        addq    $32, %rsp
        popq    %rbx
        ret

НО, если это связано с -flto (оптимизация времени ссылки):

0000000100401710 <main>:
   100401710:   53                      push   %rbx
   100401711:   48 83 ec 20             sub    $0x20,%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    $0x20,%rsp
   10040172d:   5b                      pop    %rbx
   10040172e:   c3                      retq

Ответ 3

Переупорядочение может выполняться компилятором или процессором.

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

asm volatile("" ::: "memory");

(Дополнительная информация здесь)

Обратите внимание, что это только косвенно предотвращает операции переупорядочения, если они зависят от чтения/записи.

На практике я еще не видел систему, в которой системный вызов Clock::now() имеет тот же эффект, что и такой барьер. Вы можете проверить собранную сборку.

Однако не редкость, что тестируемая функция оценивается во время компиляции. Чтобы обеспечить выполнение "реалистичного" исполнения, вам может потребоваться вывод данных для foo() из ввода/вывода или чтения volatile.


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

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


@Руслан поднимает фундаментальную проблему: насколько реалистично это измерение?

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

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

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

Ответ 4

Язык С++ определяет, что можно наблюдать несколькими способами.

Если 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() с нулевыми побочными эффектами, но вы выигрываете, вы теряете некоторые.

Ответ 5

Нет, не может. Согласно стандарту С++ [intro.execution]:

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

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

Обратите внимание, что условия для правила as-if для применения здесь не выполняются. Неразумно думать, что любой компилятор сможет доказать, что переупорядочение вызовов для получения системного времени не повлияет на поведение наблюдаемой программы. Если бы было обстоятельство, при котором два вызова, чтобы получить время, можно было переупорядочить без изменения наблюдаемого поведения, было бы крайне неэффективно фактически создавать компилятор, который анализирует программу с достаточным пониманием, чтобы иметь возможность сделать это с уверенностью.

Ответ 6

Нет.

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

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

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

Ответ 7

функция noinline + черный ящик встроенной сборки + полные зависимости данных

Это основано на fooobar.com/info/18756/... но поскольку я не вижу четкого обоснования того, почему ::now() не может быть переупорядочен там, я бы предпочел быть параноиком и поместить его внутрь noinline функция вместе с ассм.

Таким образом, я уверен, что переупорядочение не может произойти, так как noinline "связывает" зависимость ::now и зависимость данных.

main.cpp

#include <chrono>
#include <iostream>
#include <string>

// noinline ensures that the ::now() cannot be split from the __asm__
template <class T>
__attribute__((noinline)) auto get_clock(T& value) {
    // Make the compiler think we actually use / modify the value.
    // It can't "see" what is going on inside the assembly string.
    __asm__ __volatile__("" : "+m"(value));
    return std::chrono::high_resolution_clock::now();
}

template <class T>
static T foo(T niters) {
    T result = 42;
    for (T i = 0; i < niters; ++i) {
        result = (result * result) - (3 * result) + 1;
    }
    return result;
}

int main(int argc, char **argv) {
    unsigned long long input;
    if (argc > 1) {
        input = std::stoull(argv[1], NULL, 0);
    } else {
        input = 10000;
    }

    // Must come before because it could modify input
    // which is passed as a reference.
    auto t1 = get_clock(input);
    auto output = foo(input);
    // Must come after as it could use the output.
    auto t2 = get_clock(output);
    std::cout << "output " << output << std::endl;
    std::cout << "time "
              << std::chrono::duration_cast<std::chrono::nanoseconds>(t2 - t1).count()
              << std::endl;
}

Скомпилируйте и запустите:

g++ -ggdb3 -O3 -std=c++14 -Wall -Wextra -pedantic -o main.out main.cpp
./main.out 1000
./main.out 10000
./main.out 100000

Единственным незначительным недостатком этого метода является то, что мы добавляем одну дополнительную инструкцию callq поверх inline метода. objdump -CD показывает, что main содержит:

    11b5:       e8 26 03 00 00          callq  14e0 <auto get_clock<unsigned long long>(unsigned long long&)>
    11ba:       48 8b 34 24             mov    (%rsp),%rsi
    11be:       48 89 c5                mov    %rax,%rbp
    11c1:       b8 2a 00 00 00          mov    $0x2a,%eax
    11c6:       48 85 f6                test   %rsi,%rsi
    11c9:       74 1a                   je     11e5 <main+0x65>
    11cb:       31 d2                   xor    %edx,%edx
    11cd:       0f 1f 00                nopl   (%rax)
    11d0:       48 8d 48 fd             lea    -0x3(%rax),%rcx
    11d4:       48 83 c2 01             add    $0x1,%rdx
    11d8:       48 0f af c1             imul   %rcx,%rax
    11dc:       48 83 c0 01             add    $0x1,%rax
    11e0:       48 39 d6                cmp    %rdx,%rsi
    11e3:       75 eb                   jne    11d0 <main+0x50>
    11e5:       48 89 df                mov    %rbx,%rdi
    11e8:       48 89 44 24 08          mov    %rax,0x8(%rsp)
    11ed:       e8 ee 02 00 00          callq  14e0 <auto get_clock<unsigned long long>(unsigned long long&)>

Итак, мы видим, что foo был встроен, но get_clock не было и окружают его.

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

00000000000014e0 <auto get_clock<unsigned long long>(unsigned long long&)>:
    14e0:       e9 5b fb ff ff          jmpq   1040 <std::chrono::_V2::system_clock::now()@plt>

Поскольку точность часов сама по себе ограничена, я думаю, что вряд ли вы сможете заметить временные эффекты одного дополнительного jmpq. Обратите внимание, что один call требуется независимо, так как ::now() находится в общей библиотеке.

Call ::now() из встроенной сборки с зависимостью данных

Это было бы наиболее эффективным возможным решением, преодолевая даже дополнительный jmpq упомянутый выше.

К сожалению, это очень сложно сделать правильно, как показано на: Вызов printf в расширенном встроенном ASM

Однако, если ваше измерение времени может быть выполнено непосредственно во встроенной сборке без вызова, то этот метод можно использовать. Это относится, например, к инструкциям волшебного инструментария gem5, x86 RDTSC (не уверен, что это больше является представительным) и, возможно, к другим счетчикам производительности.

Протестировано с GCC 8.3.0, Ubuntu 19.04.