Почему icc не может обработать подсказки отрасли компиляции в разумных пределах?
Разработчик может использовать __builtin_expect
встроенный, чтобы помочь компилятору понять, в каком направлении находится ветка скорее всего, пойдет.
В будущем мы можем получить стандартный атрибут для этой цели, но на сегодняшний день по крайней мере все clang
, icc
и gcc
вместо этого поддерживайте нестандартный __builtin_expect
.
Однако icc
, похоже, создает странно ужасный код, когда вы используете его 1. То есть код, который использует встроенный, строго хуже кода без него, независимо от того, в каком направлении производится прогноз.
Возьмем, например, следующую игрушечную функцию:
int foo(int a, int b)
{
do {
a *= 77;
} while (b-- > 0);
return a * 77;
}
Из трех компиляторов icc
является единственным, который компилирует это в оптимальный скалярный цикл из 3 инструкций:
foo(int, int):
..B1.2: # Preds ..B1.2 ..B1.1
imul edi, edi, 77 #4.6
dec esi #5.12
jns ..B1.2 # Prob 82% #5.18
imul eax, edi, 77 #6.14
ret
Оба gcc и Clang управляют пропуском простое решение и используйте 5 инструкций.
С другой стороны, когда вы используете макросы likely
или unlikely
в условии цикла, icc
идет полностью braindead:
#define likely(x) __builtin_expect((x), 1)
#define unlikely(x) __builtin_expect((x), 0)
int foo(int a, int b)
{
do {
a *= 77;
} while (likely(b-- > 0));
return a * 77;
}
Этот цикл функционально эквивалентен предыдущему циклу (поскольку __builtin_expect
просто возвращает свой первый аргумент), но icc производит некоторый ужасный код
foo(int, int):
mov eax, 1 #9.12
..B1.2: # Preds ..B1.2 ..B1.1
xor edx, edx #9.12
test esi, esi #9.12
cmovg edx, eax #9.12
dec esi #9.12
imul edi, edi, 77 #8.6
test edx, edx #9.12
jne ..B1.2 # Prob 95% #9.12
imul eax, edi, 77 #11.15
ret #11.15
Функция удвоилась по размеру до 10 команд, и (что еще хуже!) критический цикл более чем удвоился до 7 команд с длинной цепью критической зависимости, включающей cmov
и другие странные вещи.
То же самое верно, если вы используете unlikely
подсказку, а также во всех версиях icc (13, 14, 17), что godbolt поддерживает. Таким образом, генерация кода строго хуже, независимо от подсказки, и независимо от фактического поведения во время выполнения.
Ни один gcc
и clang
не подвергается деградации при использовании советов.
Что с этим?
1 По крайней мере, в первом и последующих примерах, которые я пробовал.
Ответы
Ответ 1
Мне кажется, что ICC-ошибка. Этот код (доступен для godbolt)
int c;
do
{
a *= 77;
c = b--;
}
while (likely(c > 0));
которые просто используют вспомогательный локальный var c
, выдает результат без шаблона edx = !!(esi > 0)
foo(int, int):
..B1.2:
mov eax, esi
dec esi
imul edi, edi, 77
test eax, eax
jg ..B1.2
все еще не оптимальный (он мог обойтись без eax
).
Я не знаю, является ли официальная политика ICC о __builtin_expect
полной поддержкой или поддержкой совместимости.
Этот вопрос кажется более подходящим для Официальный форум ICC.
Я пробовал размещать эту тему там, но я не уверен, что я сделал хорошую работу (я был испорчен SO).
Если они ответят мне, я обновлю этот ответ.
ИЗМЕНИТЬ
У меня есть и ответ на форуме Intel, они записали эту проблему в своей системе слежения.
Как и сегодня, это кажется ошибкой.
Ответ 2
Не позволяйте инструкциям обмануть вас. Важна производительность.
Рассмотрим это довольно грубое испытание:
#include "stdafx.h"
#include <windows.h>
#include <iostream>
int foo(int a, int b) {
do { a *= 7; } while (b-- > 0);
return a * 7;
}
int fooA(int a, int b) {
__asm {
mov esi, b
mov edi, a
mov eax, a
B1:
imul edi, edi, 7
dec esi
jns B1
imul eax, edi, 7
}
}
int fooB(int a, int b) {
__asm {
mov esi, b
mov edi, a
mov eax, 1
B1:
xor edx, edx
test esi, esi
cmovg edx, eax
dec esi
imul edi, edi, 7
test edx, edx
jne B1
imul eax, edi, 7
}
}
int main() {
DWORD start = GetTickCount();
int j = 0;
for (int aa = -10; aa < 10; aa++) {
for (int bb = -500; bb < 15000; bb++) {
j += foo(aa, bb);
}
}
std::cout << "foo compiled (/Od)\n" << "j = " << j << "\n"
<< GetTickCount() - start << "ms\n\n";
start = GetTickCount();
j = 0;
for (int aa = -10; aa < 10; aa++) {
for (int bb = -500; bb < 15000; bb++) {
j += fooA(aa, bb);
}
}
std::cout << "optimal scalar\n" << "j = " << j << "\n"
<< GetTickCount() - start << "ms\n\n";
start = GetTickCount();
j = 0;
for (int aa = -10; aa < 10; aa++) {
for (int bb = -500; bb < 15000; bb++) {
j += fooB(aa, bb);
}
}
std::cout << "use likely \n" << "j = " << j << "\n"
<< GetTickCount() - start << "ms\n\n";
std::cin.get();
return 0;
}
выводит результат:
foo compiled (/Od)
j = -961623752
4422 мс
оптимальный скаляр j = -961623752
1656 мс
использовать вероятность
j = -961623752
1641 мс
Это, естественно, полностью зависит от процессора (тестируется здесь на Haswell i7), но оба цикла asm обычно почти идентичны по производительности при тестировании по диапазону входов. Многое из этого связано с выбором и упорядочением инструкций, способствующих использованию конвейерной обработки команд (латентности), прогнозирования ветвления и других аппаратных оптимизаций в ЦП.
Настоящий урок, когда вы оптимизируете, - это то, что вам нужно профилировать - это чрезвычайно сложно сделать, проверяя необработанную сборку.
Даже давая сложное испытание, где likely(b-- >0)
не верно в течение трети времени:
for (int aa = -10000000; aa < 10000000; aa++) {
for (int bb = -3; bb < 9; bb++) {
j += fooX(aa, bb);
}
}
приводит к:
foo compiled (/Od): 1844мс
оптимальный скаляр: 906 мс
Вероятность использования: 1187мс
Это неплохо. Что вы должны иметь в виду, так это то, что компилятор, как правило, сделает все возможное без вашего вмешательства. Использование __builtin_expect
и тому подобное должно быть действительно ограничено случаями, когда у вас есть существующий код, который вы профилировали, и что вы определенно идентифицировали как "горячие точки" и как имеющие проблемы с конвейером или прогнозом. Этот тривиальный пример - идеальный случай, когда компилятор почти наверняка поступит правильно, без помощи от вас.
Включая __builtin_expect
, вы просите компилятор обязательно скомпилировать по-другому - более сложный путь с точки зрения чистого количества инструкций, но более интеллектуальный способ, заключающийся в том, что он структурирует сборку таким образом, что помогает процессору лучше прогнозировать ветвление. В этом случае игры с чистым регистром (как в этом примере) на карту не так много, но если это улучшает прогноз в более сложном цикле, возможно, вы избавитесь от плохого неверного предсказания, промахов кэш-памяти и соответствующего побочного ущерба, тогда, вероятно, стоит использовать,
Я думаю, что здесь довольно ясно, по крайней мере, что, когда ветка на самом деле вероятна, мы почти полностью восстанавливаем полную производительность оптимального цикла (что, я думаю, впечатляет). В тех случаях, когда "оптимальная петля" является более сложной и менее тривиальной, мы можем ожидать, что кодеген действительно улучшит скорость предсказания ветвления (что и есть на самом деле). Я думаю, что это действительно случай, если вы не нуждаетесь, не используйте его.
По теме likely
vs unlikely
, создающей ту же сборку, это не означает, что компилятор разбит - это просто означает, что тот же самый код эффективен независимо от того, взято - пока это в основном что-то, это хорошо (в данном случае). Кодекен предназначен для оптимизации использования конвейера команд и для содействия предсказанию ветвления, которое он выполняет. Хотя мы видели некоторое снижение производительности в смешанном случае выше, нажатие цикла в основном unlikely
восстанавливает производительность.
for (int aa = -10000000; aa < 10000000; aa++) {
for (int bb = -30; bb < 1; bb++) {
j += fooX(aa, bb);
}
}
foo скомпилировано (/Od): 2453ms
оптимальный скаляр: 1968 мс
Вероятность использования: 2094 мс