Почему 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 мс