Google Benchmark Frameworks DoNotOptimize

Меня немного смущает реализация функции void DoNotOptimize Google Benchmark Framework (определение отсюда):

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

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

Таким образом, он материализует переменную и, если она не является константой, также говорит компилятору забыть что-либо о ее предыдущем значении. ("+r" - это операнд RMW).

И также всегда использует "memory" clobber, который является барьером компилятора против переупорядочивания загрузок / хранилищ, то есть убедитесь, что все глобально доступные объекты имеют свою память, синхронизированную с абстрактной машиной C ++, и предполагают, что они также могут были изменены.


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

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

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

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

template< class T >
inline __attribute__((always_inline)) 
void do_not_optimize( T&& value ) noexcept {
    if constexpr( std::is_pointer_v< T > ) {
        asm volatile("":"+m"(value)::"memory");
    } else {
        asm volatile("":"+r"(value)::);
    }
}

person Hymir    schedule 25.03.2021    source источник
comment
Приведите пример, в котором в настоящее время он работает не так, как ожидалось.   -  person rustyx    schedule 25.03.2021
comment
Это более или менее общий вопрос, * могло ли * случиться так, что регистр «пролился» (что привело бы к ненужным / не связанным с тестами накладным расходам).   -  person Hymir    schedule 25.03.2021


Ответы (1)


Вам интересно, что такое "memory" clobber? Да, это может вызвать утечку других вещей, но иногда это то, что вы хотите между итерациями чего-то, что вы пытаетесь обернуть в цикл повтора.

Обратите внимание, что "memory" clobber не влияет на объекты, которые невозможно получить из глобальных переменных. (Анализ побега). Таким образом, это не приведет к тому, что такие вещи, как счетчик циклов в for(int i = ...), будут пролиты / перезагружены.

Материализация значения указанной переменной в регистре (и забвение ее значения для постоянного распространения или целей CSE) - это как раз суть этой функции, и это дешево. Если что-то действительно не оптимизируется, значение уже будет в регистре.

(Если это не случай, когда tmp1 = a+b; / tmp2 = tmp1+c, но компилятор предпочел бы сначала сделать b+c. В этом случае принудительная материализация tmp1 заставит его действительно сделать a+b. Обычно это не проблема, потому что люди обычно не используют DoNotOptimize на временные конструкции, которые являются частью более крупного расчета.)


Я думаю, что эта ошибка преднамеренно связана с блокировкой большего количества вещей, таких как создание множества инвариантов циклов и других CSE, или уменьшение количества вещей между итерациями, или повторение цикла в тесте. Довольно часто можно увидеть, что люди используют benchmark::DoNotOptimize() только для окончательного результата вычисления или чего-то подобного; если бы у него не было затирания памяти, было бы еще меньше шансов остановить компилятор от подготовки значения (или некоторых инвариантных частей) один раз и просто mov материализовать его в регистре на каждой итерации.

Люди, которые точно понимают, что они пытаются протестировать, достаточно хорошо, чтобы проверять сгенерированный компилятором asm, наверняка могут захотеть использовать asm("" : "+g"(var));, чтобы компилятор материализовал его и забыл, что он знает о значении, без запуска каких-либо утечек других глобальных переменных. .

("+r,m" - это обходной путь для clang, который имеет тенденцию изобретать временную память для "+rm" или "+g". GCC выбирает регистр, когда это возможно.)


"+m" для указателей

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

Или по-другому без затирания памяти:

asm volatile("" : "+r"(ptr), "+m"(*ptr));

Или для всего массива объектов с указателем (Как я могу указать, что можно использовать память, на которую * указывает * встроенный аргумент ASM?)

// deref pointer-to-array of unspecified size
asm volatile("" : "+r"(ptr), "+m"( *(T (*)[]) ptr  );

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

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

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

asm( "nop  # mem operand picked %0" : "+m" (*ptr) );

Вам не нужен nop, это может быть просто строка комментария asm, такая как # hi mom, operand at %0, но проводник компилятора Godbolt (https://godbolt.org/z/doPGsse9c для этого примера) фильтрует комментарии по умолчанию, чтобы было удобно использовать инструкции. Однако это даже не обязательно должно быть действительным, если вы просто хотите посмотреть на вывод asm GCC. например nop # mem operand picked 40(%rdi) для int *ptr = func_arg+10;.

Шаблоны asm GCC являются чисто заменой текста, как printf, для помещения текста в выходной файл в те позиции, где GCC решает развернуть оператор asm. А вот Clang - другое дело; он имеет встроенный ассемблер, работающий на встроенном ассемблере.

person Peter Cordes    schedule 25.03.2021