Этот эффект происходит только в -O0
(или с volatile
) и является результатом того, что компилятор хранит ваши переменные в памяти (а не в регистрах). Вы ожидаете, что это просто представит фиксированное количество дополнительной задержки в цепочках зависимостей, переносимых al oop через i
, x
и y
, но современные процессоры не так просты.
На Intel Sandybridge- Семейство процессоров, задержка пересылки хранилища ниже , когда UOP загрузки выполняется через некоторое время после хранилища, данные которого перезагружаются, а не сразу. Итак, пустой l oop с l oop счетчик в памяти - худший случай. Я не понимаю, какой выбор дизайна ЦП может привести к этой микроархитектурной причуде, но это реальная вещь. скомпилировано без оптимизации , по крайней мере, для процессоров семейства Intel Sandybridge.
Это одна из основных причин , почему вы не должны тестировать на -O0
: узкие места отличаются от реально оптимизированного кода. См. Почему clang создает неэффективный asm с -O0 (для этой простой суммы с плавающей запятой)? , чтобы узнать больше о том, почему компиляторы специально делают такие ужасные asm.
Микротестирование - это жесткий ; вы можете измерить что-либо правильно, только если вы можете заставить компиляторы генерировать реалистично оптимизированные asm-циклы для того, что вы пытаетесь измерить. (И даже в этом случае вы измеряете только пропускную способность или задержку, а не оба; это разные вещи для отдельных операций на конвейерных ЦП с нарушением порядка: Какие соображения go при прогнозировании задержки для операции на современных суперскалярных процессорах и как я могу их вычислить вручную? )
См. @ rcgldr ответ для измерения + объяснение того, что произойдет с циклами, которые хранят переменные в регистрах.
С clang, benchmark::DoNotOptimize(x1 += 31)
также деоптимизируется, сохраняя x
в памяти, но с G CC он просто остается в регистре. К сожалению, @ SashaKnorre ответ использовал clang на QuickBench, а не g cc, чтобы получить результаты, аналогичные вашему -O0
asm. Он действительно показывает стоимость большого количества коротких NOP, скрытых узким местом через память, и небольшое ускорение, когда эти NOP задерживают перезагрузку следующей итерации, достаточной для того, чтобы переадресация хранилища достигла хорошего случая с более низкой задержкой. (Думаю, QuickBench работает на серверных процессорах Intel Xeon с той же микроархитектурой внутри каждого ядра процессора, что и настольная версия того же поколения.)
Предположительно, все машины x86, на которых вы тестировали, имели процессоры Intel последних 10 лет, иначе аналогичный эффект наблюдается на AMD. Вполне вероятно, что аналогичный эффект будет иметь место на любом процессоре ARM, который использует ваш RPi, если ваши измерения действительно были значимыми. В противном случае может быть еще один случай увидеть то, что вы ожидали ( систематическая ошибка подтверждения ), особенно если вы тестировали с включенной оптимизацией.
Я тестировал это с разными уровнями оптимизации кода (-O0
, -O1
, -O2
, -O3
) [...] Но я всегда получал аналогичный результат
I добавлено, что в вопросе об оптимизации указано, чтобы избежать ответов «не измерять неоптимизированный код», потому что оптимизация - это не то, о чем я спрашиваю.
(позже из комментариев) Об оптимизации: да, я воспроизвел это с разными уровнями оптимизации, но поскольку циклы были оптимизированы, время выполнения было слишком коротким, чтобы сказать наверняка.
Так что на самом деле вы не воспроизвести этот эффект для -O1
или выше, вы просто видели то, что хотели видеть (смещение подтверждения), и в основном утверждали, что эффект был таким же. Если бы вы точно сообщили свои данные (измеримый эффект при -O0
, пустая временная область на -O1
и выше), я мог бы ответить сразу.
См. Idiomati c способ оценки производительности? - если ваше время не увеличивается линейно с увеличением количества повторов, вы не измеряете то, что, по вашему мнению, вы измеряете. Кроме того, эффекты запуска (такие как холодные кеши, программные ошибки страниц, ленивое динамическое c связывание и динамическое c частота ЦП) могут легко привести к тому, что первая пустая временная область будет медленнее, чем вторая.
I Предположим, вы поменяли местами циклы только при тестировании на -O0
, иначе вы исключили бы какой-либо эффект на -O1
или выше с этим тестовым кодом.
l oop с включенной оптимизацией:
Как видите, на Godbolt , g cc полностью удаляет l oop с включенной оптимизацией. Иногда G CC оставляет пустые циклы в покое, например, он думает, что задержка была намеренной, но здесь это даже не l oop. Время не масштабируется ни с чем, и обе временные области выглядят одинаково, вот так:
orig_main:
...
call std::chrono::_V2::system_clock::now() # demangled C++ symbol name
mov rbp, rax # save the return value = start
call std::chrono::_V2::system_clock::now()
# end in RAX
Таким образом, единственная инструкция в временной области сохраняет start
в регистр с сохранением вызовов. Вы буквально ничего не измеряете в своем исходном коде.
С помощью Google Benchmark мы можем получить asm, который не оптимизирует работу, но который не сохраняет / не перезагружается, чтобы создать новые узкие места :
#include <benchmark/benchmark.h>
static void TargetFunc(benchmark::State& state) {
uint64_t x2 = 0, y2 = 0;
// Code inside this loop is measured repeatedly
for (auto _ : state) {
benchmark::DoNotOptimize(x2 += 31);
benchmark::DoNotOptimize(y2 += 31);
}
}
// Register the function as a benchmark
BENCHMARK(TargetFunc);
# just the main loop, from gcc10.1 -O3
.L7: # do{
add rax, 31 # x2 += 31
add rdx, 31 # y2 += 31
sub rbx, 1
jne .L7 # }while(--count != 0)
Я предполагаю, что benchmark::DoNotOptimize
- это что-то вроде asm volatile("" : "+rm"(x) )
( GNU C inline asm ), чтобы компилятор материализовал x
в регистр или память, и предположить, что lvalue было изменено этим пустым оператором asm. (т.е. забудьте все, что он знал о значении, блокировке распространения констант, CSE и т. д.). Это могло бы объяснить, почему clang сохраняет / перезагружает в память, в то время как G CC выбирает регистр: это давняя ошибка упущенной оптимизации с clang's встроенная поддержка asm. Ему нравится выбирать память, когда есть выбор, который иногда можно обойти с помощью многоальтернативных ограничений, таких как "+r,m"
. Но не здесь; Мне пришлось просто отказаться от альтернативы памяти; мы не хотим, чтобы компилятор в любом случае проливал / перезагружался в память. asm ( Godbolt ), например G CC. Мы получаем практически идентичный внутренний l oop с 3 инструкциями добавления, последней из которых является add rbx, -1
/ jnz
, которая может объединяться в макросах.
static void TargetFunc(benchmark::State& state) {
uint64_t x2 = 0, y2 = 0;
// Code inside this loop is measured repeatedly
for (auto _ : state) {
x2 += 16;
y2 += 17;
asm volatile("" : "+r"(x2), "+r"(y2));
}
}
Все они должны работать на 1 тактовый цикл на итерацию на современных процессорах Intel и AMD, снова см. ответ @ rcgldr.
Конечно, это также отключает автоматическую векторизацию с SIMD, что компиляторы будут делать во многих реальных случаях использования. Или, если вы вообще использовали результат за пределами l oop, он мог бы оптимизировать повторяющееся приращение до одного умножения.
Вы не можете измерить стоимость Оператор +
в C ++ - он может компилироваться по-разному в зависимости от контекста / окружающего кода . Даже без учета l oop -инвариантных вещей, которые работают с подъемниками. например, x + (y<<2) + 4
может компилироваться в одну инструкцию LEA для x86.
На самом деле вопрос в том, почему мои компьютеры выполняют две операции быстрее, чем одну, в первую очередь в коде, где эти операции не оптимизированы
TL: DR: это не операции, это цепочка зависимостей, переносимая l oop через память, которая не дает ЦП запускать l oop с 1 тактовым циклом на итерацию, выполняя все 3 добавления параллельно на отдельных портах выполнения.
Обратите внимание, что l Приращение счетчика oop - это такая же важная операция, как и то, что вы делаете с x
(а иногда и y
).