Почему GCC не оптимизирует этот набор ветвлений и условных выражений настолько, насколько это возможно?

#c #optimization #gcc

#c #оптимизация #gcc

Вопрос:

Следующие три фрагмента кода достигают точно такого же эффекта. Тем не менее, при компиляции с -O3 в GCC 4.5.2 время для большого количества итераций довольно заметно меняется.

1 — Нормальное ветвление с использованием нескольких условий, наилучшее время 1.0:

 // a, b, c, d are set to random values 0-255 before each iteration.
if (a < 16 or b < 32 or c < 64 or d < 128) result  = a b c d;
  

2 — Ветвление, вручную с использованием побитового или для проверки условий, наилучшее время 0,92:

 if (a < 16 | b < 32 | c < 64 | d < 128) result  = a b c d;
  

3 — Наконец, получение того же результата без ветки, лучшее время 0,85:

 result  = (a b c d) * (a < 16 | b < 32 | c < 64 | d < 128);
  

Вышеуказанные времена являются лучшими для каждого метода при запуске как внутренний цикл тестовой программы, которую я сделал. Заполняется random() одинаково перед каждым запуском.

Прежде чем я сделал этот тест, я предполагал, что GCC оптимизирует различия. Особенно 2-й пример заставляет меня почесать голову. Кто-нибудь может объяснить, почему GCC не превращает подобный код в эквивалентный более быстрый код?

РЕДАКТИРОВАТЬ: исправлены некоторые ошибки, а также указано, что случайные числа создаются независимо и используются, чтобы не быть оптимизированными. Они всегда были в исходном тесте, я просто испортил код, который я вставил здесь.

Вот пример реальной контрольной функции:

 boost::random::mt19937 rng;
boost::random::uniform_int_distribution<> ranchar(0, 255);

double quadruple_or(uint64_t runs) {
  uint64_t result = 0;
  rng.seed(0);

  boost::chrono::high_resolution_clock::time_point start = 
    boost::chrono::high_resolution_clock::now();
  for (; runs; runs--) {
    int a = ranchar(rng);
    int b = ranchar(rng);
    int c = ranchar(rng);
    int d = ranchar(rng);
    if (a < 16 or b < 32 or c < 64 or d < 128) result  = a;
    if (d > 16 or c > 32 or b > 64 or a > 128) result  = b;
    if (a < 96 or b < 53 or c < 199 or d < 177) result  = c;
    if (d > 66 or c > 35 or b > 99 or a > 77) result  = d;
  }

  // Force gcc to not optimize away result.
  std::cout << "Result check " << result << std::endl;
  boost::chrono::duration<double> sec = 
    boost::chrono::high_resolution_clock::now() - start;
  return sec.count();
}
  

Полный тест можно найти здесь .

Комментарии:

1. Что такое or ? Ваш первый пример не кажется допустимым C.

2. Это (по крайней мере) синоним C для || .

3. @Mark B — Это так? С каких пор?

4. @HeathHunnicutt: По- видимому, навсегда. Они также существуют в виде набора макросов, добавленных через некоторое время после C89 (кажется, не совсем C99 — это странно).

5. @Heath Hunnicutt C 98 C.2.2.2 The tokens and, and_eq, bitand, bitor, compl, not_eq, not, or, or_eq, xor, and xor_eq are keywords in this International Standard (2.11). They do not appear as macro names defined in <ciso646> . Также см. 2.5 / 2 / таблица 2, в которой показаны прямые альтернативные сопоставления токенов.

Ответ №1:

OP немного изменился с момента моего первоначального ответа. Позвольте мне вернуться к этому здесь.

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

В случае 2 компилятор может решить выполнить все четыре сравнения, преобразовать их в результаты bool 0/1, а затем побитово or объединить все четыре части вместе, а затем выполнить одну (дополнительную) ветвь. Это, возможно, приведет к большему количеству сравнений для, возможно, меньшего количества ветвей. Похоже, что уменьшение количества ветвей повышает производительность.

В случае 3 все работает практически так же, как и 2, за исключением того, что в самом конце можно исключить еще одну ветвь, явно сообщив компилятору: «Я знаю, что результат будет равен нулю или единице, поэтому просто умножьте значение слева на это значение». Очевидно, что умножение выполняется быстрее, чем соответствующая ветвь на вашем оборудовании. Это в отличие от второго примера, где компилятор не знает диапазон возможных выходных данных из побитового or , поэтому он должен предположить, что это может быть любое целое число, и вместо этого должен выполнить сравнение и переход.

Оригинальный ответ для истории: первый случай функционально отличается от второго и третьего, если random имеет побочные эффекты (которые были бы у обычного PRNG), поэтому само собой разумеется, что компилятор может оптимизировать их по-разному. В частности, первый случай будет вызываться random столько раз, сколько необходимо для прохождения проверки, в то время как в двух других случаях random всегда будет вызываться четыре раза. Это (при random условии, что действительно отслеживается состояние) приведет к тому, что будущие случайные числа будут разными.

Разница между вторым и третьим заключается в том, что компилятор, вероятно, по какой-то причине не может определить, что результат побитового or всегда будет равен 0 или 1. Когда вы даете ему подсказку для выполнения умножения вместо ветвления, умножение, вероятно, происходит быстрее из-за конвейерной обработки.

Комментарии:

1. Мне было бы интересно увидеть разницу во времени между if (rand()>3) a и a =(rand()>3) GCC по какой-то причине не выполняет эту оптимизацию?

2. @Mooing Duck Возможно ли, что g не может определить, что побочные эффекты такие же, как у from = (представьте, что вы могли бы волшебным образом LD_PRELOAD разделяемую библиотеку, которая изменяет семантику одного из этих операторов).

3. 1, и если random() произойдет вызов rand() , то в этом нет «вероятно», это гарантированно будет PRNG, выводящий воспроизводимую последовательность для каждого начального числа.

4. @MarkB: я предполагал a , что и result были примитивными типами. Если нет, то нам нужно намного больше информации, чтобы ответить на вопрос.

5. @MarkB: Мне очень жаль. Вы полностью правы, random и это побочные эффекты. На самом деле у меня это было правильно в исходном тесте, но я ошибся, когда включил его здесь. Ваш комментарий о разнице между вторым и третьим интересным, но я не совсем уверен, что понимаю его. Не могли бы вы немного поразмыслить?

Ответ №2:

С помощью логических операторов код будет ветвиться и завершаться досрочно. Побитовые операторы всегда выполняют всю работу.

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

Он не может оптимизировать, random() потому что эта функция не является чистой (идемпотентной).

Комментарии:

1. Это объясняет, почему ветви работают медленнее. Это не объясняет, почему GCC не признает, что ветвей можно было избежать в первую очередь.

2. Потому что их нельзя было избежать в первую очередь, проще говоря: random() изменяет поведение программы, даже если вы игнорируете результат. Атрибут gcc pure позволил бы эту оптимизацию.

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

Ответ №3:

Вы всегда можете попробовать оптимизировать ветвление и умножить. Вместо:

 if (test) result = blah;
  

или

 result = blah*(test);
  

Вы можете сделать:

 result = blahamp;(-(test));
  

Если test равно false , -false==0 и (blahamp;0)==0 . Если test true , -true==~0 и (blahamp;~0)==blah . Возможно, вам придется повозиться с test as !!test , чтобы убедиться true==1 .

Комментарии:

1. Это еще одна умная концепция оптимизации, которую я раньше не замечал. 1

2. Такого рода микрооптимизацию лучше оставить компилятору.

3. @spraff: Это то, что я думал о ветвлении. Вы хотите сказать, что, например, GCC распознает, что blah * (test) всегда умножается на 1 или 0, и оптимизирует его, как указано выше? Я уже потратил свое легкомысленное пособие на исследования на этой неделе, поэтому я не буду тестировать это сам.

4. Я не знаю, будет ли это , но это возможно . Более важно, чтобы следующий пользователь мог быстро его прочитать.

Ответ №4:

На моей машине (Intel E5503) с gcc 4.5.3 я обнаружил, что версия 1, как правило, самая быстрая, хотя разница находится в пределах шума измерения (f3 является самым медленным, хотя всего на 2% медленнее, чем f1).

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

Комментарии:

1. Я разместил ссылку на фактический тест; он помещает несколько условных выражений на цикл, чтобы подчеркнуть эту часть вычисления. Требуется boost 1.47. Мне было бы интересно узнать, по-прежнему ли ваши результаты отличаются.