Почему оптимизаторы С++ имеют проблемы с этими временными переменными или, скорее, почему `v []` следует избегать в узких циклах?

В этом фрагменте кода я сравниваю производительность двух функционально идентичных циклов:

for (int i = 1; i < v.size()-1; ++i) {
  int a = v[i-1];
  int b = v[i];
  int c = v[i+1];

  if (a < b  &&  b < c)
    ++n;
}

и

for (int i = 1; i < v.size()-1; ++i) 
  if (v[i-1] < v[i]  &&  v[i] < v[i+1])
    ++n;

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

  • второй цикл составляет около 330% медленнее теперь с Clang 3.7.0
  • второй цикл примерно на 2% медленнее с gcc 4.9.3
  • второй цикл примерно на 2% медленнее с Visual С++ 2015

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

Использование временных переменных делает код быстрее, а иногда и резко. Что происходит?

Полный код, который я использую, приведен ниже:

#include <algorithm>
#include <chrono>
#include <random>
#include <iomanip>
#include <iostream>
#include <vector>

using namespace std;
using namespace std::chrono;

vector<int> v(1'000'000);

int f0()
{
  int n = 0;

  for (int i = 1; i < v.size()-1; ++i) {
    int a = v[i-1];
    int b = v[i];
    int c = v[i+1];

    if (a < b  &&  b < c)
      ++n;
  }

  return n;
}


int f1()
{
  int n = 0;

  for (int i = 1; i < v.size()-1; ++i) 
    if (v[i-1] < v[i]  &&  v[i] < v[i+1])
      ++n;

  return n;
}


int main()
{
  auto benchmark = [](int (*f)()) {
    const int N = 100;

    volatile long long result = 0;
    vector<long long>  timings(N);

    for (int i = 0; i < N; ++i) {
      auto t0 = high_resolution_clock::now(); 
      result += f();
      auto t1 = high_resolution_clock::now(); 

      timings[i] = duration_cast<nanoseconds>(t1-t0).count();
    }

    sort(timings.begin(), timings.end());
    cout << fixed << setprecision(6) << timings.front()/1'000'000.0 << "ms min\n";
    cout << timings[timings.size()/2]/1'000'000.0 << "ms median\n" << "Result: " << result/N << "\n\n";
  };

  mt19937                    generator   (31415);   // deterministic seed
  uniform_int_distribution<> distribution(0, 1023);

  for (auto& e: v) 
    e = distribution(generator);

  benchmark(f0);
  benchmark(f1);

  cout << "\ndone\n";

  return 0;
}

Ответ 1

Похоже, компилятор не знает знаний о соотношении между std::vector<>::size() и размером внутреннего векторного буфера. Рассмотрим std::vector наш пользовательский векторный объект bugged_vector с небольшой ошибкой - его ::size() может иногда быть больше, чем размер внутреннего буфера n, но только тогда v[n-2] >= v[n-1].

Затем два фрагмента снова имеют другую семантику: во-первых, имеет поведение undefined, поскольку мы обращаемся к элементу v[v.size() - 1]. Второй, однако, не имеет: из-за короткого замыкания &&, мы никогда не читаем v[v.size() - 1] на последней итерации.

Итак, если компилятор не может доказать, что наш v не является bugged_vector, он должен быть короткозамкнутым, что приводит к дополнительному скачку машинного кода.

Посмотрев на вывод сборки из clang, мы видим, что это действительно происходит.

Из проводник компилятора Godbolt, с clang 3.7.0 -O2, цикл в f0:

### f0: just the loop
.LBB1_2:                                # =>This Inner Loop Header: Depth=1
    mov     edi, ecx
    cmp     edx, edi
    setl    r10b
    mov     ecx, dword ptr [r8 + 4*rsi + 4]
    lea     rsi, [rsi + 1]
    cmp     edi, ecx
    setl    dl
    and     dl, r10b
    movzx   edx, dl
    add     eax, edx
    cmp     rsi, r9
    mov     edx, edi
    jb      .LBB1_2

И для f1:

### f1: just the loop
.LBB2_2:                                # =>This Inner Loop Header: Depth=1
    mov     esi, r10d
    mov     r10d, dword ptr [r9 + 4*rdi]
    lea     rcx, [rdi + 1]
    cmp     esi, r10d
    jge     .LBB2_4                     # <== This is Extra Jump
    cmp     r10d, dword ptr [r9 + 4*rdi + 4]
    setl    dl
    movzx   edx, dl
    add     eax, edx
.LBB2_4:                                # %._crit_edge.3
    cmp     rcx, r8
    mov     rdi, rcx
    jb      .LBB2_2

Я указал на дополнительный прыжок в f1. И, как мы (надеюсь) знаем, условные прыжки в плотных петлях плохо для производительности. (См. Руководства по производительности в x86 wiki для деталей.)

GCC и Visual Studio знают, что std::vector хорошо себя ведет и создает почти идентичную сборку для обоих фрагментов. Edit. Оказывается, clang лучше оптимизирует работу кода. Все три компилятора не могут доказать, что безопасно читать v[i + 1] перед сравнением во втором примере (или не выбирать), но только clang удается оптимизировать первый пример с дополнительной информацией, которая читает v[i + 1] либо действителен, либо UB.

Разница в производительности на 2% незначительна, может быть объяснена различным порядком или выбором некоторых инструкций.

Ответ 2

Здесь дополнительная информация для расширения на ответ @deniss, который правильно диагностировал проблему.

Кстати, это связано с самым популярным С++ Q & A всего времени "Почему обработка отсортированного массива быстрее, чем несортированный массив?" .

Основная проблема заключается в том, что компилятор должен соблюдать логический оператор AND (& &), а не загружать из v [i + 1], если первое условие не является истинным. Это является следствием семантики логического оператора И, а также семантики модели сжатой памяти, введенной с С++ 11, соответствующие статьи в черновом стандарте

5.14 Логический оператор AND [expr.log.and]

В отличие от &, & гарантирует оценку слева направо: вторая операнд не оценивается, если первый операнд false.
ISO С++ 14 Standard (проект N3797)суб >

и для умозрительных чтений

1.10 Многопоточные исполнения и расписания данных [intro.multithread]

23 [Примечание. Преобразования, которые вводят спекулятивное чтение потенциально разделяемой памяти, могут не сохранять семантику программы на С++, как это определено в этом стандарте, поскольку они потенциально могут привести к гонке данных. Однако они, как правило, действительны в контексте оптимизирующего компилятора, который нацелен на конкретную машину с четко определенной семантикой для расчётов данных. Они были бы недействительны для гипотетической машины, которая не терпима к гонкам или обеспечивает обнаружение аппаратной гонки. - end note]
Стандарт ISO С++ 14 (проект N3797)

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

Чтобы реализовать это, компилятор генерирует условную ветвь. Обычно это не заметно, потому что современные процессоры имеют очень сложное отраслевое предсказание, а скорость ошибочного предсказания обычно очень низкая. Однако данные здесь случайны - это убивает предсказание ветвления. Стоимость ошибочного предсказания - от 10 до 20 циклов ЦП, учитывая, что CPU, как правило, откладывает 2 инструкции за цикл, это эквивалентно 20-40 инструкциям. Если скорость прогноза составляет 50% (случайная), то каждая итерация имеет неверное предсказание, эквивалентное 10-20 инструкциям - HUGE.

Примечание.. Компилятор может доказать, что в этот порядок будут ссылаться элементы v[0] на v[v.size()-2], независимо от значений, которые они содержат. Это позволило бы компилятору в этом случае генерировать код, который безоговорочно загружает все, кроме последнего элемента вектора. Последний элемент вектора в v [v.size() - 1] может быть загружен только в последнюю итерацию цикла и только если первое условие истинно. Поэтому компилятор может генерировать код для цикла без ветвления короткого замыкания до последней итерации, а затем использовать другой код с ветвью короткого замыкания для последней итерации - для этого потребуется, чтобы компилятор знал, что данные являются случайными, а предсказание ветвлений бесполезно и поэтому стоит потрудиться с этим - компиляторы не так уж сложны.

Чтобы избежать условной ветки, сгенерированной логическим И (& &), и избегать загрузки ячеек памяти в локальные переменные, мы можем изменить логический оператор И в Поразрядное И, фрагмент кода здесь, результат почти в 4 раза быстрее, когда данные случайны

int f2()
{
  int n = 0;

  for (int i = 1; i < v.size()-1; ++i) 
     n += (v[i-1] < v[i])  &  (v[i] < v[i+1]); // Bitwise AND

  return n;
}

Выход

3.642443ms min
3.779982ms median
Result: 166634

3.725968ms min
3.870808ms median
Result: 166634

1.052786ms min
1.081085ms median
Result: 166634


done

Результат на gcc 5.3 на 8 раз быстрее (вживую в Coliru здесь)

g++ --version
g++ -std=c++14  -O3 -Wall -Wextra -pedantic -pthread -pedantic-errors main.cpp -lm  && ./a.out
g++ (GCC) 5.3.0
Copyright (C) 2015 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

3.761290ms min
4.025739ms median
Result: 166634

3.823133ms min
4.050742ms median
Result: 166634

0.459393ms min
0.505011ms median
Result: 166634


done

Вы можете задаться вопросом, как компилятор может оценить сравнение v[i-1] < v[i] без генерации условной ветки. Ответ зависит от цели, для x86 это возможно из-за инструкции SETcc, которая генерирует один байт-результат, 0 или 1, в зависимости от состояния в регистре EFLAGS, такое же условие, которое можно было бы использовать в условном ветвь, но без ветвления. В сгенерированном коде, предоставленном @deniss, вы можете увидеть сгенерированный setl, который устанавливает результат в 1, если выполняется условие "меньше", которое оценивается предыдущей командой сравнения:

cmp     edx, edi       ; a < b ?
setl    r10b           ; r10b = a < b ? 1 : 0
mov     ecx, dword ptr [r8 + 4*rsi + 4] ; c = v[i+1]
lea     rsi, [rsi + 1] ; ++i
cmp     edi, ecx       ; b < c ?
setl    dl             ; dl = b < c ? 1 : 0
and     dl, r10b       ; dl &= r10b
movzx   edx, dl        ; edx = zero extended dl
add     eax, edx       ; n += edx

Ответ 3

f0 и f1 семантически различны.

x() && y() включает короткое замыкание в случае, когда x() является ложным, как мы знаем. Это означает, что если x() является ложным, то y() не следует оценивать.

Это предотвращает предварительную выборку данных для оценки y() и (по крайней мере, на clang) вызывает вставку условного перехода, что приводит к промахам ветки-прогноза.

Добавление еще двух тестов подтверждает точку.

#include <algorithm>
#include <chrono>
#include <random>
#include <iomanip>
#include <iostream>
#include <vector>

using namespace std;
using namespace std::chrono;

vector<int> v(1'000'000);

int f0()
{
    int n = 0;

    for (int i = 1; i < v.size()-1; ++i) {
        int a = v[i-1];
        int b = v[i];
        int c = v[i+1];

        if (a < b  &&  b < c)
            ++n;
    }

    return n;
}


int f1()
{
    int n = 0;

    auto s = v.size() - 1;
    for (size_t i = 1; i < s; ++i)
        if (v[i-1] < v[i]  &&  v[i] < v[i+1])
            ++n;

    return n;
}

int f2()
{
    int n = 0;

    auto s = v.size() - 1;
    for (size_t i = 1; i < s; ++i)
    {
        auto t1 = v[i-1] < v[i];
        auto t2 = v[i] < v[i+1];
        if (t1 && t2)
            ++n;
    }

    return n;
}

int f3()
{
    int n = 0;

    auto s = v.size() - 1;
    for (size_t i = 1; i < s; ++i)
    {
        n += 1 * (v[i-1] < v[i]) * (v[i] < v[i+1]);
    }

    return n;
}



int main()
{
    auto benchmark = [](int (*f)()) {
        const int N = 100;

        volatile long long result = 0;
        vector<long long>  timings(N);

        for (int i = 0; i < N; ++i) {
            auto t0 = high_resolution_clock::now();
            result += f();
            auto t1 = high_resolution_clock::now();

            timings[i] = duration_cast<nanoseconds>(t1-t0).count();
        }

        sort(timings.begin(), timings.end());
        cout << fixed << setprecision(6) << timings.front()/1'000'000.0 << "ms min\n";
        cout << timings[timings.size()/2]/1'000'000.0 << "ms median\n" << "Result: " << result/N << "\n\n";
    };

    mt19937                    generator   (31415);   // deterministic seed
    uniform_int_distribution<> distribution(0, 1023);

    for (auto& e: v) 
        e = distribution(generator);

    benchmark(f0);
    benchmark(f1);
    benchmark(f2);
    benchmark(f3);

    cout << "\ndone\n";

    return 0;
}

результаты (яблочный clang, -O2):

1.233948ms min
1.320545ms median
Result: 166850

3.366751ms min
3.493069ms median
Result: 166850

1.261948ms min
1.361748ms median
Result: 166850

1.251434ms min
1.353653ms median
Result: 166850

Ответ 4

Ни один из ответов до сих пор не дал версию f(), что gcc или clang могут полностью оптимизироваться. Все они генерируют asm, который сравнивает каждую итерацию. См. код с выходом asm в проводнике компиляторов Godbolt. (Знание базовых знаний для прогнозирования производительности из выхода asm: Руководство по микроархитектуре Agner Fog и другие ссылки на тег wiki. Как всегда, он обычно лучше всего работает с счетчиками производительности, чтобы найти киоски.)

v[i-1] < v[i] - это работа, которую мы уже сделали в последней итерации, когда мы оценили v[i] < v[i+1]. Теоретически, помогая компилятору grok, который позволил бы ему оптимизировать лучше (см. f3()). На практике это приводит к тому, что в некоторых случаях происходит победа в автоинтеграции, а gcc испускает код с партитурами с частичным регистром, даже с -mtune=core2, где эта огромная проблема.

Включение вручную v.size() - 1 из проверки верхней границы цикла, похоже, помогает. OP f0 и f1 фактически не перекомпилируют v.size() из указателей начала/конца в v, но почему-то все еще оптимизируются менее эффективно, чем при вычислении size_t upper = v.size() - 1 вне цикла (f2() и f4()).

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


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


Результаты на 64-битном Ubuntu 15.10, на Core2 E6600 (микроархитектура Merom/Conroe).

clang++-3.8 -O3 -march=core2   |   g++ 5.2 -O3 -march=core2         | gcc 5.2 -O2 (default -mtune=generic)
f0    1.825ms min(1.858 med)   |   5.008ms min(5.048 med)           | 5.000 min(5.028 med)
f1    4.637ms min(4.673 med)   |   4.899ms min(4.952 med)           | 4.894 min(4.931 med)
f2    1.292ms min(1.323 med)   |   1.058ms min(1.088 med) (autovec) | 4.888 min(4.912 med)
f3    1.082ms min(1.117 med)   |   2.426ms min(2.458 med)           | 2.420 min(2.465 med)
f4    1.291ms min(1.341 med)   |   1.022ms min(1.052 med) (autovec) | 2.529 min(2.560 med)

Результаты будут отличаться от оборудования Intel SnB-семейства, особенно. IvyBridge и позже, где вообще не было бы частичного замедления регистров. Core2 ограничен медленными невыложенными нагрузками и только одной нагрузкой за цикл. Петли могут быть достаточно маленькими, чтобы декодировать это не проблема.


f0 и f1:

gcc 5.2: OP f0 и f1 оба создают веткистые петли и не будут автоматически прорисовываться. Тем не менее, f0 использует только одну ветвь и использует странную setl sil/cmp sil, 1/sbb eax, -1 для выполнения второй половины сравнения короткого замыкания. Поэтому он все равно выполняет оба сравнения на каждой итерации.

clang 3.8: f0: только одна загрузка на итерацию, но оба сравниваются и and вместе. f1: обе сравнивают каждую итерацию, одну с веткой, чтобы сохранить семантику C. Две нагрузки на итерацию.


int f2() {
  int n = 0;
  size_t upper = v.size()-1;   // difference from f0: hoist upper bound and use size_t loop counter
  for (size_t i = 1; i < upper; ++i) {
    int a = v[i-1], b = v[i], c = v[i+1];
    if (a < b  &&  b < c)
      ++n;
  }
  return n;
}

gcc 5.2 -O3: авто-векторизация с тремя нагрузками для получения трех векторов смещения, необходимых для создания одного вектора из 4 результатов сравнения. Кроме того, после объединения результатов двух инструкций pcmpgtd, они сравнивают их с вектором all-zero, а затем с масками. Zero уже является элементом идентификации для добавления, так что действительно глупо.

clang 3.8 -O3: разворачивает: каждая итерация выполняет две нагрузки, три cmp/setcc, два and s и два add s.


int f4() {
  int n = 0;

  size_t upper = v.size()-1;
  for (size_t i = 1; i < upper; ++i) {
      int a = v[i-1], b = v[i], c = v[i+1];
      bool ab_lt = a < b;
      bool bc_lt = b < c;

      n += (ab_lt & bc_lt);  // some really minor code-gen differences from f2: auto-vectorizes to better code that runs slightly faster even for this large problem size
  }

  return n;
}
  • gcc 5.2 -O3: autovectorizes как f2, но без дополнительного pcmpeqd.
  • gcc 5.2 -O2: не исследовал, почему это в два раза быстрее, чем f2.
  • clang -O3: примерно тот же код, что и f2.

Попытка захвата компилятора

int f3() {
  int n = 0;
  int a = v[0], b = v[1];   // These happen before checking v.size, defeating the loop vectorizer or something
  bool ab_lt = a < b;

  size_t upper = v.size()-1;
  for (size_t i = 1; i < upper; ++i) {
      int c = v[i+1];       // only one load and compare inside the loop
      bool bc_lt = b < c;

      n += (ab_lt & bc_lt);

      ab_lt = bc_lt;
      a = b;                // unused inside the loop, only the compare result is needed
      b = c;
  }
  return n;
}
  • clang 3.8 -O3: разворачивается с четырьмя нагрузками внутри цикла (обычно clang нравится разворачиваться на 4, если не существует сложных зависимых от цикла зависимостей).
    4 cmp/setcc, 4x и /movzx, 4x add. Так что clang сделал именно то, что я надеялся, и сделал почти оптимальный скалярный код. Это была самая быстрая не-векторная версия, а (на ядре2, где movups невысокие нагрузки медленны) выполняется так же быстро, как gcc-векторизованные версии.

  • gcc 5.2 -O3: Не удается автоматизировать векторизацию. Моя теория заключается в том, что доступ к массиву вне цикла смущает автоинтервализатор. Может быть, потому что мы делаем это перед проверкой v.size() или, может быть, просто вообще.

    Скомпилирует скалярный код, на который мы надеемся, с одной загрузкой, один cmp/setcc и один and за итерацию. Но gcc создает партию с неполным регистром, даже с -mtune=core2, где огромная проблема (от 2 до 3 циклов, чтобы вставить слияние uop при чтении широкой рег после записи только ее части). (setcc доступен только с 8-разрядным размером операнда, который IMO - это то, что AMD должно было изменить, когда они спроектировали ISA AMD64.) Это главная причина, по которой gcc-код работает на 2,5x медленнее, чем clang's.

    /li >

## the loop in f3(), from gcc 5.2 -O3 (same code with -O2)
.L31:
    add     rcx, 1    # i,
    mov     edi, DWORD PTR [r10+rcx*4]        # a, MEM[base: _19, index: i_13, step: 4, offset: 0]
    cmp     edi, r8d  # a, a                 # gcc verbose-asm comments are a bit bogus here: one of these `a`s is from the last iteration, so this is really comparing c, b
    mov     r8d, edi  # a, a
    setg    sil     #, tmp124
    and     edx, esi  # D.111089, tmp124     # PARTIAL-REG STALL: reading esi after writing sil
    movzx   edx, dl                          # using movzx to widen sil to esi would have solved the problem, instead of doing it after the and
    add     eax, edx  # n, D.111085          # n += ...
    cmp     r9, rcx   # upper, i
    mov     edx, esi  # ab_lt, tmp124
    jne     .L31      #,
    ret