Pull to refresh

Comments 22

имхо, подобные детали лучше вынести из зоны ответственности разработчика прикладного софта в зону ответственности разработчика компиляторов, а точнее оптимизаторов.

UFO just landed and posted this here
Есть компиляторы которые не перестают удивлять rsdn.org/forum/cpp/6722631.1
… компилятор способный по не объяснимым причинам замедлить исполнение куска кода более чем в 100 раз...
Я бы сказал «по непредсказуемым причинам», а не «по необьяснимым».

Просто надо понимать, что оптимизирующий компилятор — это, всё-таки, немножко чёрной магии.

Вот родственный пример для GCC:

$ gcc -O1 test2.cc test1.cc -o test
$ time ./test
    0m51.66s real     0m51.47s user     0m00.02s system
$ gcc -O3 test2.cc test1.cc -o test
$ time ./test
    1m11.64s real     1m11.30s user     0m00.04s system
$ cat /proc/cpuinfo  | grep name
model name	: Intel(R) Atom(TM) CPU  Z3560  @ 1.00GHz
model name	: Intel(R) Atom(TM) CPU  Z3560  @ 1.00GHz
model name	: Intel(R) Atom(TM) CPU  Z3560  @ 1.00GHz
model name	: Intel(R) Atom(TM) CPU  Z3560  @ 1.00GHz

Переход от -O1 к -O3 = замедление, причём заметно большее, чем описываемое в статье!

Исходники
test.h:
#include <inttypes.h>

struct pair {
  uint64_t low;
  uint64_t hi;
};

pair add(pair& a, pair& b);

test1.c:
#include "test.h"

pair add(pair& a, pair& b) {
 pair s;
 s.low = a.low + b.low;
 s.hi = a.hi + b.hi + (s.low < a.low); //carry
 return s;
}

test2.c:
#include <stdio.h>

#include "test.h"

int main() {
  pair a = { 0x4243444546474849, 0x4243444546474849 };
  pair b = { 0x5758595a5b5c5d5e, 0x5758595a5b5c5d5e };
  for (uint64_t i=0;i<10000000000;++i) {
    a = add(a, b);
  }
}


Если взглянуть на сгенерированный код — то сразу понятно, откуда замедление, но совершенно непонятно, как переход от -O1 к -O3 его провоцировал…

Просто в GCC производительностью занились много лет назад и замедление в 100 раз всегда считалось чем-то ужасным, так что такие катастрофы, как в MSVC уже подвыловлены. А разработчики MSVC занялись производительностью не так и давно, так что…
>> но совершенно непонятно, как переход от -O1 к -O3 его провоцировал
Лечится ключом -fno-expensive-optimizations
Какой-то проход видимо посчитал, что вероятность (s.low >= a.low) крайне низка.
Правильно предсказанный бранч занимает 0 тактов, а adc на интел целых 2.
Это можно увидеть используя
pair add(pair& a, pair& b) __attribute__((hot));
pair add(pair& a, pair& b) __attribute__((cold));
Во втором случае он перемещает mov ecx, 1 прямо за переход, не оптимизируя fetch, так сказать.

Кроме того, лишь x86 бэкенд генерит переход — на других процах такой фигни я не обнаружил.
* вероятность (s.low >= a.low) крайне высока
Правильно предсказанный бранч занимает 0 тактов, а adc на интел целых 2.
Однако add таки один такт занимает, а потому замена связки add+adc на add+jc+add+add — это в чистом виде пессимизация. Независимо от вероятностей код занимает либо 3 тракта, либо ещё больше. А в оригинале — он только 3 такта всегда занимал…
>> add+jc+add+add
В этой связке первые две команды add не являются зависимыми (и они могут быть исполнены параллельно).
Цепочка зависимости это лишь последние add+add вместо add + adc.
На процах Интел до Skylake (IIRC), латентность ADC 2 такта.
_возможно_ этот такт и перевешивает. Я лишь предпогаю, как вы понимаете.
В этой связке первые две команды add не являются зависимыми (и они могут быть исполнены параллельно).
Это если вы эту команду отдельно, не в цикле исполняете. Но там — это не так важно. А в цикле — вы займёте больше исполняемых устройств, задержка тут не так важна. Особенно если у вас иногда всё же предстказание переходов не срабатывает (в 25% случаев, ага), и вы получаете хорошую-такую задержку…
Короче всё намного проще.
Всё решается даже до преобразования в p-code.

При -O1 -fexpensive-optimizations срабатывает стадия 188t.widening_mul
(при обычном -O1 её нет)

godbolt.org/g/vDfnVb

Происходит преобразование

<bb 2> [100.00%]:
_1 = a_11(D)->low;
_2 = b_12(D)->low;
_3 = _1 + _2;
# DEBUG s$low => _3
_4 = a_11(D)->hi;
_5 = b_12(D)->hi;
_6 = _4 + _5;
_7 = _1 > _3;
_8 = (long unsigned int) _7;
_9 = _6 + _8;
# DEBUG s$hi => _9
MEM[(struct pair *)&D.2410] = _3;
MEM[(struct pair *)&D.2410 + 8B] = _9;

вот в это

<bb 2> [100.00%]:
_1 = a_11(D)->low;
_2 = b_12(D)->low;
_15 = ADD_OVERFLOW (_1, _2);
_3 = REALPART_EXPR <_15>;
_16 = IMAGPART_EXPR <_15>;
# DEBUG s$low => _3
_4 = a_11(D)->hi;
_5 = b_12(D)->hi;
_6 = _4 + _5;
_7 = _16 != 0;
_8 = (long unsigned int) _7;
_9 = _6 + _8;
# DEBUG s$hi => _9
MEM[(struct pair *)&D.2410] = _3;
MEM[(struct pair *)&D.2410 + 8B] = _9;

Имеем

_7 = _1 > _3;
_8 = (long unsigned int) _7;
_9 = _6 + _8;
vs.
_7 = _16 != 0;
_8 = (long unsigned int) _7;
_9 = _6 + _8;
не тут ли потерялся перенос?

Также если открыть 311t.statistics, то можно заметить что
266 combine «three-insn combine» «pair add(pair&, pair&)» 1
не применяется. Возможно это следствие.
Возможно. Но я просто о том, что странные эффекты могут возникать в любом компиляторе. В случае с этой конкретной проблемой мы просто «забили», так как наш проект (по многим причинам, но в основном чтобы использовать libc++, а не libstdc++) переехал на clang. Соответственно «ездить по мозгам» разработчикам GCC оказалось некому…

Занимательная статья — вступление и выводы вроде более или менее просты в понимании, но вот целый параграф с момента "немного забегая вперед" выхреначил из контекста вон. Все равно после прочтения возникло большое желание еще почитать про выравнивание инструкций.

Вполне достаточно почитать/посмотреть умопянутую в тексте презентацию Zia Ansari:
«Causes of Performance Instability due to Code Placement in X86»
было бы все так очевидно,

в свое время пытался максимально оптимизировать, выработались некоторые практики, типо: такие структуры лучше обходить через for, другие — foreach, для больших структур в некоторых местах применить указатели и т.д. и т.п… потом столкнулся с проблемой: желание выработать «культуру письма» кончилось сломаной головой =)…

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

больше такой херней не занимаюсь, и как в первом комменте написали: оптимизацию оставить разработчикам компиляторов, программисту нужно логику программы описывать, а не средствами ЯП пытаться залезть под капот компилятору =)

PS: занимательная статья
Всё так, я своего нерадивого коллегу пытаюсь убедить, что использование ассемблерных вставок для матрасчётов в современном C++ — это моветон. Успехи так себе, конечно, с восклицаниями «Видишь? ВИДИШЬ!?» меня парировали тем, что при -O1 и -O3 перемешивание операндов в выражении позволяло уменьшить выражение на две инструкции, а на асме — ещё на одну. «С ПЛАВАЮЩЕЙ ЗАПЯТОЙ!!!»

Но мы ведь не про это, а про использование компилятора, который имеет просто неприличное множество настроек оптимизации (даже если это MSVC). Раз они есть и даются нам, будет глупо их игнорировать. Да, поиск оптимальных настроек полным перебором — это через чур, но потеребонькать основные оптимизирующие опции всё же стоит. Задачи мы решаем разные, платформы у нас разные и цели у нас разные, а компилятор один на всех — и не очень смышлёный. Нужно давать ему подсказки.
>> Всё так, я своего нерадивого коллегу пытаюсь убедить, что использование ассемблерных вставок для матрасчётов в современном C++ — это моветон.
Инлайн-асмом что-ли? Зачем, когда есть инстринсики?
Так что либо интринсики, либо нормальный асм (если заставить компилятор генерить внятный код не получается).
> Успехи так себе
За это надо сразу бить Кнутом
«premature optimization is the root of all evil»

А можно ссылку на оригинал? Хочется поделиться с иностранными коллегами

Я удивлен что clang не применяет выравнивание по умолчанию. Тем более что его реккомендуют делать сами производители процессоров (например читал про такое в cortex-a57 optimization guide).
В gcc такие выравнивания делаются автоматически на -О2 и выше.
gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html

Флаги -falign-…

Скорее всего это баг в clang. Возможно его стоит зарепортить разработчикам.
Тогда уж это в LLVM баг, clang же не занимается генерацией машинного кода самостоятельно.
По умолчанию выравнивает на 16 байт — размер instruction fetch (у intel).
Это видно в первом же листинге
4046c0: entry
4046d0: loop start

Если каждый цикл на 32 байта выравнивать, вырастет размер кода + что-то может наоборот не влезть в DSB или сместиться и вызвать замедление. В презентации Zia Ansari про это говорилось.
Sign up to leave a comment.