Как функция GoogleDoNotOptimize() обеспечивает упорядочение операторов

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

Для полноты, вот его определение (для данных clang и неконстантных данных):

      template <class Tp>
inline BENCHMARK_ALWAYS_INLINE void DoNotOptimize(Tp& value) {
  asm volatile("" : "+r,m"(value) : : "memory");
}

Насколько я понимаю, мы можем использовать это в таком коде:

      start_time = time();
bench_output = run_bench(bench_inputs);
result = time() - start_time;

Чтобы тест оставался в критическом разделе:

      start_time = time();
DoNotOptimize(bench_inputs);
bench_output = run_bench(bench_inputs);
DoNotOptimise(bench_output);
result = time() - start_time;

В частности, я не понимаю, почему это гарантирует (не так ли?), Что run_bench()это не перемещается выше.

(Кто-то спросил именно об этом в этом комментарии , но я не понимаю ответа).

Как я понял, выше DoNotOptimze() делает несколько вещей:

  • Он заставляет стек, так как он передается по ссылке C ++. У вас не может быть указателя на регистр, поэтому он должен быть в памяти.
  • Поскольку сейчас находится в стеке, последующее затирание памяти (как это сделано в ограничениях asm) заставит компилятор предположить, что value одновременно читается и записывается при вызове DoNotOptimize(value).
  • (мне не ясно, если +r,mограничение актуально. Насколько мне известно, это говорит о том, что сам указатель может храниться в регистре или в памяти, но само значение указателя может быть прочитано и / или записано.)

И здесь у меня все становится нечетким.

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

      start_time = time(); // on the stack
DoNotOptimize(bench_inputs); // reads start_time, writes bench_inputs
bench_output = run_bench(bench_inputs)

Но если он хранится не в памяти, а вместо этого в регистре, то затирание памяти не будет затираться. start_time, Правильно? В этом случае желаемый порядок start_time = time() а также DoNotOptimize(bench_inputs) теряется, и компилятор может делать:

      DoNotOptimize(bench_inputs); // only writes bench_inputs
bench_output = run_bench(bench_inputs)
start_time = time(); // in a register

Очевидно, я что-то неправильно понял. Кто-нибудь может помочь объяснить? Спасибо :)

Мне интересно, связано ли это с тем, что оптимизация переупорядочения происходит до выделения регистров, и, следовательно, предполагается, что в это время все будет выделено стеком. Но если бы это было так, то DoNotOptimize() будет лишним, так как ClobberMemory() будет достаточно.

1 ответ

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

заказывается по вычисление вывода из ввода посредством зависимости данных вычисления от ввода, а вывода от вычисления, как объяснил Чендлер Каррут в связанных с вами вопросах и ответах . Clobber не имеет значения для этой части.


clobber похож на вызов функции, не являющейся встроенной

Заявление содержит треп. Что касается оптимизатора, это эквивалентно вызову непрозрачной функции: предполагается, что он читает и записывает каждый глобально доступный объект 1. (Даже те, о которых этот модуль компиляции может не знать.)

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

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

Сноска 1: Глобально достижимый = любой объект, на который может указывать любая гипотетическая глобальная переменная. т.е. что угодно, кроме локальных переменных в этой функции или памяти, только что выделенной с помощью , если анализ escape может доказать, что ничто вне этой функции не может иметь указателей на них.


Как работает инструкция

Я думаю, вы серьезно не понимаете, как работает asm. сообщает компилятору материализовать значение в регистре (или в памяти, если он хочет), а затем использовать значение в конце (пустого) шаблона asm в качестве нового значения этого объекта C++.

Таким образом, это заставляет компилятор фактически материализовать (производить) значение где-то, что означает, что оно должно быть вычислено. И это означает, что он должен забыть то, что он ранее знал о значении (например, что это была постоянная времени компиляции 5, или неотрицательная, или что-то еще), потому что модификатор объявляет операнд чтения / записи.

Смысл на входе - предотвратить постоянное распространение, которое позволило бы тесту оптимизироваться.

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

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

Компилятор должен предположить, что оператор asm изменяет значение, например насколько он знает, наряду с изменением значения в памяти любого / любого другого объекта, кроме частных локальных переменных, которые не были операндами, например, clobber не мешает компилятору хранить счетчик локального цикла в памяти. (Здесь не используется пустая строка шаблона asm; программы не содержат таких операторов asm случайно, поэтому никто не хочет, чтобы они были оптимизированы.)


Заблуждения о ссылочном аргументе и выборе "m"

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

Функция C++, содержащая оператор, может быть встроена в строку, позволяя выполнять оптимизацию функции arg по ссылке. (Это даже объявлено для принудительного встраивания даже при отключенной оптимизации, хотя в этом случае ссылочная переменная не будет оптимизирована.)

Конечный результат такой, как если бы оператор asm использовался непосредственно для переменной C++, переданной в DoNotOptimize. например как asm volatile("" : "+r,m"(foo) :: "memory")

Компилятор всегда может выбрать регистр, если захочет, например, выбрав загрузку значения переменной в регистр перед утверждение. (И если семантика C++ требует обновления значения переменной в памяти, а также выдачи инструкции сохранения после инструкции asm.)

Например, мы видим, что GCC делает это. (Думаю, я мог бы использовать в качестве примера, но я просто выбрал как способ показать, что компилятор выбрал для расположения операнда в качестве альтернативы чистый комментарий, поэтому обозреватель компилятора Godbolt не отфильтрует его.)

      void foo(int *p)
{
    asm volatile("nop # operand picked %0" : "+r,m" (p[4]) );
}
      # GCC 11.2 -O2
foo(int*):
        movl    16(%rdi), %eax
        nop # operand picked %eax
        movl    %eax, 16(%rdi)
        ret

vs. clang, если оставить значение в памяти, поэтому каждая инструкция в шаблоне asm будет обращаться к памяти, а не к регистру. (Если были инструкции).

      # clang 12.0.1 -O2 -fPIE
foo(int*):                               # @foo(int*)
        nop     # operand picked 16(%rdi)
        retq

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