Почему ОДНА основная операция c arithmeti c в теле l oop выполняется МЕНЬШЕ, ЧЕМ ДВЕ операции arithmeti c? - PullRequest
15 голосов
/ 29 мая 2020

Пока я экспериментировал с измерением времени выполнения арифметических c операций, обнаружил очень странное поведение. Кодовый блок, содержащий for l oop с одной арифметической c операцией в теле l oop, был всегда выполнялся медленнее, чем идентичный кодовый блок, но с двумя арифметическими c операциями. в корпусе for l oop. Вот код, который я закончил тестировать:

#include <iostream>
#include <chrono>

#define NUM_ITERATIONS 100000000

int main()
{
    // Block 1: one operation in loop body
    {
        int64_t x = 0, y = 0;
        auto start = std::chrono::high_resolution_clock::now();

        for (long i = 0; i < NUM_ITERATIONS; i++) {x+=31;}

        auto end = std::chrono::high_resolution_clock::now();
        std::chrono::duration<double> diff = end-start;
        std::cout << diff.count() << " seconds. x,y = " << x << "," << y << std::endl;
    }

    // Block 2: two operations in loop body
    {
        int64_t x = 0, y = 0;
        auto start = std::chrono::high_resolution_clock::now();

        for (long i = 0; i < NUM_ITERATIONS; i++) {x+=17; y-=37;}

        auto end = std::chrono::high_resolution_clock::now();
        std::chrono::duration<double> diff = end-start;
        std::cout << diff.count() << " seconds. x,y = " << x << "," << y << std::endl;
    }

    return 0;
}

Я тестировал это с разными уровнями оптимизации кода (-O0, -O1, -O2, -O3), с разными онлайн-компиляторами ( например onlinegdb.com ), на моей рабочей машине, на моем домашнем компьютере P C и ноутбуке, на RaspberryPi и на компьютере моего коллеги. Я переставил эти два кодовых блока, повторил их, изменил константы, изменил операции (+, -, <<, =, et c.), Изменил целочисленные типы. Но я всегда получал аналогичный результат: блок с одной строкой в ​​l oop это SLOWER , чем блок с двумя строками:

1.05681 секунда. x, y = 3100000000,0
0,90414 секунды. x, y = 1700000000, -3700000000

Я проверил вывод сборки на https://godbolt.org/, но все выглядело так, как я ожидал: второй блок только что выполнил еще одну операцию на выходе сборки.

Три операции всегда вели себя должным образом: они медленнее, чем одна , и быстрее, чем четыре . Так почему же две операции вызывают такую ​​аномалию?

Изменить:

Позвольте мне повторить: у меня такое поведение наблюдается на всех моих Windows и Unix машин с неоптимизированным кодом. Я посмотрел на сборку, которую выполняю (Visual Studio, Windows), и вижу инструкции, которые хочу там протестировать. В любом случае, если l oop оптимизирован, в оставшемся коде я не о чем спрашиваю. Я добавил, что в вопросе есть уведомление об оптимизации, чтобы избежать ответов «не измерять не оптимизированный код», потому что оптимизация - это не то, о чем я спрашиваю. На самом деле вопрос в том, почему мои компьютеры выполняют две операции быстрее, чем одну, прежде всего в коде, где эти операции не оптимизированы. Разница во времени выполнения на моих тестах составляет 5-25% (довольно заметно).

Ответы [ 5 ]

10 голосов
/ 04 июня 2020

Этот эффект происходит только в -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).

6 голосов
/ 01 июня 2020

ETA: Это было предположение, и Питер Кордес привел очень хороший аргумент в пользу того, почему это неверно. Go проголосуйте за ответ Питера.

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


Обоснованное предположение:

Это комбинированный эффект конвейерной обработки, отключения частей ядра и динамического c масштабирования частоты .

Конвейер современных процессоров, так что одновременно может выполняться несколько инструкций. Это возможно, потому что процессор на самом деле работает с микрооперациями, а не с инструкциями уровня сборки, которые мы обычно называем машинным языком. Процессоры «планируют» микрооперации, отправляя их в разные части микросхемы, отслеживая зависимости между инструкциями. . Одна команда arithmeti c, повторяемая снова и снова, требует только одного ALU. Использование двух ALU не помогает, потому что следующая операция зависит от завершения текущей, поэтому второй ALU будет просто ждать.

Но в вашем тесте с двумя выражениями выражения независимы. Чтобы вычислить следующее значение y, вам не нужно ждать завершения текущей операции на x. Теперь, из-за функций энергосбережения, второй ALU может сначала отключиться. Ядро могло бы выполнить несколько итераций, прежде чем поймете, что оно может использовать второй ALU. В этот момент он может включить второй ALU, и большая часть двух выражений l oop будет выполняться так же быстро, как и одно выражение l oop. Таким образом, вы можете ожидать, что два примера займут примерно одинаковое время.

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

Я предполагаю, что это делается с помощью эвристики. В случае, если второй ALU остается выключенным, heuristi c может решить, что не стоит повышать частоту. В случае, когда два ALU включены и работают на максимальной скорости, он может решить повысить тактовую частоту. Таким образом, вариант с двумя выражениями, который уже должен быть примерно таким же быстрым, как и вариант с одним выражением, на самом деле работает с более высокой средней тактовой частотой, что позволяет ему выполнять в два раза больше работы за немного меньшее время. Учитывая ваши цифры, разница составляет около 14%. Моя машина Windows простаивает на частоте около 3,75 ГГц, и если я немного сделаю sh, построив решение в Visual Studio, часы поднимутся примерно до 4,25 ГГц (глядя на вкладку «Производительность» в диспетчере задач). Разница в тактовой частоте составляет 13%, так что мы находимся в правильном направлении.

5 голосов
/ 01 июня 2020

Я разделил код на C ++ и ассемблер. Я просто хотел проверить циклы, поэтому не вернул сумму (и). Я использую Windows, соглашение о вызовах - rcx, rdx, r8, r9,, количество l oop находится в rcx. Код добавляет немедленные значения к 64-битным целым числам в стеке.

Я получаю одинаковое время для обоих циклов, отклонение менее 1%, такое же или одно на 1% быстрее другого.

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

Изменение test2 на 3 добавления в память, в итоге примерно на 6% медленнее, 4 добавления в память, на 7,5% медленнее.

Моя система - процессор Intel 3770K 3,5 ГГц, материнская плата Intel DP67BG, DDR3 1600 9-9 -9-27 памяти, Win 7 Pro 64 бит, Visual Studio 2015.

        .code
        public  test1
        align   16
test1   proc
        sub     rsp,16
        mov     qword ptr[rsp+0],0
        mov     qword ptr[rsp+8],0
tst10:  add     qword ptr[rsp+8],17
        dec     rcx
        jnz     tst10
        add     rsp,16
        ret     
test1   endp

        public  test2
        align 16
test2   proc
        sub     rsp,16
        mov     qword ptr[rsp+0],0
        mov     qword ptr[rsp+8],0
tst20:  add     qword ptr[rsp+0],17
        add     qword ptr[rsp+8],-37
        dec     rcx
        jnz     tst20
        add     rsp,16
        ret     
test2   endp

        end

Я также тестировал с немедленным добавлением в регистр, 1 или 2 регистра в пределах 1% (любой может быть быстрее, но мы '' г ожидать, что они оба будут выполняться с 1 итерацией / такт на Ivy Bridge, учитывая его 3 целочисленных порта ALU; Какие соображения go при прогнозировании задержки для операций на современных суперскалярных процессорах и как c an Я вычисляю их вручную? ).

3 регистра в 1,5 раза длиннее, что несколько хуже, чем идеальные 1,333 цикла / итераций из 4 мопов (включая l oop счетчик, объединенный с макросами dec / jnz) для 3 внутренних портов ALU с идеальным планированием.

4 регистра, в 2,0 раза длиннее, узкое место во внешнем интерфейсе: Снижается ли производительность при выполнении циклов, количество uop которых не кратно ширины процессора? . Haswell и более поздние микроархитектуры справятся с этим лучше.

        .code
        public  test1
        align   16
test1   proc
        xor     rdx,rdx
        xor     r8,r8
        xor     r9,r9
        xor     r10,r10
        xor     r11,r11
tst10:  add     rdx,17
        dec     rcx
        jnz     tst10
        ret     
test1   endp

        public  test2
        align 16
test2   proc
        xor     rdx,rdx
        xor     r8,r8
        xor     r9,r9
        xor     r10,r10
        xor     r11,r11
tst20:  add     rdx,17
        add     r8,-37
        dec     rcx
        jnz     tst20
        ret     
test2   endp

        public  test3
        align 16
test3   proc
        xor     rdx,rdx
        xor     r8,r8
        xor     r9,r9
        xor     r10,r10
        xor     r11,r11
tst30:  add     rdx,17
        add     r8,-37
        add     r9,47
        dec     rcx
        jnz     tst30
        ret     
test3   endp

        public  test4
        align 16
test4   proc
        xor     rdx,rdx
        xor     r8,r8
        xor     r9,r9
        xor     r10,r10
        xor     r11,r11
tst40:  add     rdx,17
        add     r8,-37
        add     r9,47
        add     r10,-17
        dec     rcx
        jnz     tst40
        ret     
test4   endp

        end
2 голосов
/ 01 июня 2020

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

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

Но похоже, что @Adrian McCarthy сделал все правильно с динамическим c масштабированием частоты.

В любом случае тесты говорят, что вставка некоторых NOP может помочь с этой проблемой, при этом 15 NOP после x + = 31 в блоке 1 приводят к почти такой же производительности, как и в блоке 2. Действительно поразительно, как 15 NOP в одной инструкции l oop body повышают производительность.

http://quick-bench.com/Q_7HY838oK5LEPFt-tfie0wy4uA

Я также пробовал -OFast думающие компиляторы могли бы быть достаточно умными, чтобы выбросить часть памяти кода, вставляя такие NOP, но, похоже, это не так. http://quick-bench.com/so2CnM_kZj2QEWJmNO2mtDP9ZX0

Изменить : благодаря @PeterCordes стало ясно, что оптимизации никогда не работали должным образом в тестах выше (поскольку глобальная переменная требовала добавления инструкций в доступ к памяти), новый тест http://quick-bench.com/HmmwsLmotRiW9xkNWDjlOxOTShE ясно показывает, что производительность блока 1 и блока 2 одинакова для переменных стека. Но NOP по-прежнему могут помочь в однопоточном приложении с l oop доступом к глобальной переменной, которую вы, вероятно, не должны использовать в этом случае и просто назначьте глобальную переменную локальной переменной после l oop.

Edit 2 : На самом деле оптимизации никогда не работали из-за макросов быстрого тестирования, которые делают доступ к переменным нестабильным, предотвращая важные оптимизации. Логично загружать переменную только один раз, поскольку мы изменяем ее только в l oop, поэтому узким местом является непостоянная или отключенная оптимизация. Таким образом, этот ответ в основном неверен, но, по крайней мере, он показывает, как NOP могут ускорить выполнение неоптимизированного кода, если это имеет какой-либо смысл в реальном мире (есть более эффективные способы, такие как счетчики сегментов).

1 голос
/ 03 июня 2020

Процессоры настолько сложны в наши дни, что мы можем только догадываться.

Сборка, созданная вашим компилятором, не является тем, что на самом деле выполняется. Микрокод / ​​прошивка / что-то еще в вашем ЦП интерпретирует и превращает в инструкции для своего механизма исполнения, во многом как языки JIT, такие как C# или java.

Здесь следует учитывать следующее: для каждого l oop есть не 1 или 2 инструкции, а n + 2, поскольку вы также увеличиваете и сравниваете i с вашим количеством итераций. В подавляющем большинстве случаев это не имеет значения, но здесь это имеет значение, поскольку тело l oop настолько простое.

Давайте посмотрим на сборку:

Некоторые определяют:

#define NUM_ITERATIONS 1000000000ll
#define X_INC 17
#define Y_INC -31

C / C ++:

for (long i = 0; i < NUM_ITERATIONS; i++) { x+=X_INC; }

ASM:

    mov     QWORD PTR [rbp-32], 0
.L13:
    cmp     QWORD PTR [rbp-32], 999999999
    jg      .L12
    add     QWORD PTR [rbp-24], 17
    add     QWORD PTR [rbp-32], 1
    jmp     .L13
.L12:

C / C ++:

for (long i = 0; i < NUM_ITERATIONS; i++) {x+=X_INC; y+=Y_INC;}

ASM:

    mov     QWORD PTR [rbp-80], 0
.L21:
    cmp     QWORD PTR [rbp-80], 999999999
    jg      .L20
    add     QWORD PTR [rbp-64], 17
    sub     QWORD PTR [rbp-72], 31
    add     QWORD PTR [rbp-80], 1
    jmp     .L21
.L20:

Итак, обе сборки выглядят очень похоже. Но тогда давайте дважды подумаем: современные процессоры имеют ALU, которые работают со значениями, превышающими размер их регистра. Таким образом, есть вероятность, что в первом случае операции над x и i выполняются на одном и том же вычислительном блоке. Но тогда вам придется снова прочитать i, так как вы ставите условие на результат этой операции. А чтение означает ожидание.

Итак, в первом случае для итерации по x ЦП, возможно, должен быть синхронизирован c с итерацией по i.

Во втором случае , возможно, x и y обрабатываются в другом модуле, нежели тот, который имеет дело с i. Фактически, ваше тело l oop работает параллельно, чем состояние, в котором оно движется. И вот ваш процессор выполняет вычисления и вычисления, пока кто-нибудь не скажет ему остановиться. Неважно, зайдет ли он слишком далеко, возврат на несколько циклов по-прежнему прекрасен по сравнению с только что полученным временем.

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

Одно из решений - полностью избавиться от него, используя время l oop: C / C ++:

while (x < (X_INC * NUM_ITERATIONS)) { x+=X_INC; }

ASM:

.L15:
    movabs  rax, 16999999999
    cmp     QWORD PTR [rbp-40], rax
    jg      .L14
    add     QWORD PTR [rbp-40], 17
    jmp     .L15
.L14:

Другой - использовать предшествующее ключевое слово «register» C: C / C ++:

register long i;
for (i = 0; i < NUM_ITERATIONS; i++) { x+=X_INC; }

ASM:

    mov     ebx, 0
.L17:
    cmp     rbx, 999999999
    jg      .L16
    add     QWORD PTR [rbp-48], 17
    add     rbx, 1
    jmp     .L17
.L16:

Вот мои результаты:

x1 для: 10,2985 секунд. x, y = 17000000000,0
x1, а: 800049 секунд. x, y = 17000000000,0
x1 регистр: 7,31426 секунд. x, y = 17000000000,0
x2 для: 9,30073 секунды. x, y = 17000000000, -31000000000
x2, а: 8,88801 секунды. x, y = 17000000000, -31000000000
x2 регистр для: 8,70302 секунды. x, y = 17000000000, -31000000000

Код здесь: https://onlinegdb.com/S1lAANEhI

...