Почему выравнивание цикла на 32 байт делает код быстрее?

Посмотрите на этот код:

one.cpp:

bool test(int a, int b, int c, int d);

int main() {
        volatile int va = 1;
        volatile int vb = 2;
        volatile int vc = 3;
        volatile int vd = 4;

        int a = va;
        int b = vb;
        int c = vc;
        int d = vd;

        int s = 0;
        __asm__("nop"); __asm__("nop"); __asm__("nop"); __asm__("nop");
        __asm__("nop"); __asm__("nop"); __asm__("nop"); __asm__("nop");
        __asm__("nop"); __asm__("nop"); __asm__("nop"); __asm__("nop");
        __asm__("nop"); __asm__("nop"); __asm__("nop"); __asm__("nop");
        for (int i=0; i<2000000000; i++) {
                s += test(a, b, c, d);
        }

        return s;
}

two.cpp:

bool test(int a, int b, int c, int d) {
        // return a == d || b == d || c == d;
        return false;
}

В one.cpp есть 16 nop. Вы можете комментировать/декомментировать их, чтобы изменить выравнивание точки входа цикла между 16 и 32. Я скомпилировал их с помощью g++ one.cpp two.cpp -O3 -mtune=native.

Вот мои вопросы:

  • 32-выровненная версия быстрее, чем версия с 16-ю строками. На Sandy Bridge разница составляет 20%; на Хасуэлл, 8%. В чем разница?
  • с 32-строчной версией, код работает на той же скорости на Sandy Bridge, не имеет значения, какой оператор возврата находится в файле two.cpp. Я думал, что версия return false должна быть быстрее, по крайней мере, немного. Но нет, точно такая же скорость!
  • Если я удаляю volatile из one.cpp, код становится медленнее (Haswell: до: ~ 2.17 с, после: ~ 2.38 сек). Почему это? Но это только происходит, когда цикл выровнен до 32.

Тот факт, что 32-выровненная версия быстрее, мне странна, потому что Архитектуры Intel® 64 и IA-32 Справочное руководство по оптимизации говорит (стр. 3-9):

Правило сборки/компилятора. Правило 12. (M-воздействие, общая общность H). Вся ветка цели должны быть выровнены по 16 байт.

Еще один маленький вопрос: есть ли какие-либо трюки, чтобы сделать только этот цикл 32-выровненным (так что остальная часть кода могла бы продолжать использовать выравнивание по 16 байт)?

Примечание. Я пробовал компиляторы gcc 6, gcc 7 и clang 3.9, те же результаты.


Здесь код с volatile (код тот же для выравнивания 16/32, только адрес отличается):

0000000000000560 <main>:
 560:   41 57                   push   r15
 562:   41 56                   push   r14
 564:   41 55                   push   r13
 566:   41 54                   push   r12
 568:   55                      push   rbp
 569:   31 ed                   xor    ebp,ebp
 56b:   53                      push   rbx
 56c:   bb 00 94 35 77          mov    ebx,0x77359400
 571:   48 83 ec 18             sub    rsp,0x18
 575:   c7 04 24 01 00 00 00    mov    DWORD PTR [rsp],0x1
 57c:   c7 44 24 04 02 00 00    mov    DWORD PTR [rsp+0x4],0x2
 583:   00 
 584:   c7 44 24 08 03 00 00    mov    DWORD PTR [rsp+0x8],0x3
 58b:   00 
 58c:   c7 44 24 0c 04 00 00    mov    DWORD PTR [rsp+0xc],0x4
 593:   00 
 594:   44 8b 3c 24             mov    r15d,DWORD PTR [rsp]
 598:   44 8b 74 24 04          mov    r14d,DWORD PTR [rsp+0x4]
 59d:   44 8b 6c 24 08          mov    r13d,DWORD PTR [rsp+0x8]
 5a2:   44 8b 64 24 0c          mov    r12d,DWORD PTR [rsp+0xc]
 5a7:   0f 1f 44 00 00          nop    DWORD PTR [rax+rax*1+0x0]
 5ac:   66 2e 0f 1f 84 00 00    nop    WORD PTR cs:[rax+rax*1+0x0]
 5b3:   00 00 00 
 5b6:   66 2e 0f 1f 84 00 00    nop    WORD PTR cs:[rax+rax*1+0x0]
 5bd:   00 00 00 
 5c0:   44 89 e1                mov    ecx,r12d
 5c3:   44 89 ea                mov    edx,r13d
 5c6:   44 89 f6                mov    esi,r14d
 5c9:   44 89 ff                mov    edi,r15d
 5cc:   e8 4f 01 00 00          call   720 <test(int, int, int, int)>
 5d1:   0f b6 c0                movzx  eax,al
 5d4:   01 c5                   add    ebp,eax
 5d6:   83 eb 01                sub    ebx,0x1
 5d9:   75 e5                   jne    5c0 <main+0x60>
 5db:   48 83 c4 18             add    rsp,0x18
 5df:   89 e8                   mov    eax,ebp
 5e1:   5b                      pop    rbx
 5e2:   5d                      pop    rbp
 5e3:   41 5c                   pop    r12
 5e5:   41 5d                   pop    r13
 5e7:   41 5e                   pop    r14
 5e9:   41 5f                   pop    r15
 5eb:   c3                      ret    
 5ec:   0f 1f 40 00             nop    DWORD PTR [rax+0x0]

Без изменчивости:

0000000000000560 <main>:
 560:   55                      push   rbp
 561:   31 ed                   xor    ebp,ebp
 563:   53                      push   rbx
 564:   bb 00 94 35 77          mov    ebx,0x77359400
 569:   48 83 ec 08             sub    rsp,0x8
 56d:   66 0f 1f 84 00 00 00    nop    WORD PTR [rax+rax*1+0x0]
 574:   00 00 
 576:   66 2e 0f 1f 84 00 00    nop    WORD PTR cs:[rax+rax*1+0x0]
 57d:   00 00 00 
 580:   b9 04 00 00 00          mov    ecx,0x4
 585:   ba 03 00 00 00          mov    edx,0x3
 58a:   be 02 00 00 00          mov    esi,0x2
 58f:   bf 01 00 00 00          mov    edi,0x1
 594:   e8 47 01 00 00          call   6e0 <test(int, int, int, int)>
 599:   0f b6 c0                movzx  eax,al
 59c:   01 c5                   add    ebp,eax
 59e:   83 eb 01                sub    ebx,0x1
 5a1:   75 dd                   jne    580 <main+0x20>
 5a3:   48 83 c4 08             add    rsp,0x8
 5a7:   89 e8                   mov    eax,ebp
 5a9:   5b                      pop    rbx
 5aa:   5d                      pop    rbp
 5ab:   c3                      ret    
 5ac:   0f 1f 40 00             nop    DWORD PTR [rax+0x0]

Ответ 1

Это не отвечает точке 2 (return a == d || b == d || c == d; с той же скоростью, что и return false). Это еще один интересный вопрос, так как он должен скомпилировать несколько инструкций с инструкциями типа uop-cache.


Тот факт, что 32-выровненная версия работает быстрее, мне странна, потому что [Intel руководство говорит, чтобы выровнять до 32]

Этот совет по оптимизации-рекомендации является очень общим руководством и, безусловно, не означает, что больше никогда не помогает. Обычно это не так, и отступы до 32 будут скорее ранить, чем помочь. (Промахи I-cache, пропуски ITLB и больше байтов кода для загрузки с диска).

Фактически, выравнивание 16B редко требуется, особенно на процессорах с кешем uop. Для небольшого цикла, который может выполняться из буфера цикла, выравнивание обычно не имеет значения.


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

Компиляторы обычно по умолчанию выравнивают ветки цикла и точки входа функции, но обычно не выравнивают другие цели ветвления. Стоимость выполнения NOP (и разбухания кода) часто больше, чем вероятная стоимость невынастроенной цели ветвления без петли.


Выравнивание кода имеет некоторые прямые и некоторые косвенные эффекты. Прямые эффекты включают кэш uop в семействе Intel SnB. Например, см. Выравнивание ветвей для циклов с использованием микрокодированных инструкций для процессоров Intel SnB-семейства.

В другом разделе Руководство по оптимизации Intel подробно описано, как работает кеш uop:

2.3.2.2 Декодированный ICache:

  • Все микрооперации в пути (строка кэша uop) представляют собой команды, которые являются статически смежными в коде и имеют свои EIP в пределах такой же выровненный 32-байтовый регион. (Я думаю, это означает указание, что проходит через границу, идет в кэше uop для блока содержащий его начало, а не конец. Инструкции по растягиванию должны пойти куда-нибудь, и целевой адрес ветки, который будет запускать инструкция является началом insn, поэтому наиболее полезно положить ее в строка для этого блока).
  • Команда multi micro-op не может быть разделена между путями.
  • Инструкция, которая включает MSROM, использует весь путь.
  • Допускается использование до двух ветвей.
  • Пара макроконфигурированных инструкций хранится как один микрооператор.

См. также Руководство микроаргата Agner Fog. Он добавляет:

  • Безусловный переход или вызов всегда заканчиваются линией кэша μop
  • много других вещей, которые, вероятно, здесь не актуальны.

Кроме того, если ваш код не подходит для кеша uop, он не может работать из буфера цикла.


Косвенные эффекты выравнивания включают:

  • больший/меньший размер кода (пропуски кеша L1I, TLB). Не относится к вашему тесту.
  • который связывает псевдонимы друг с другом в BTB (буфере целевых буферов).

Если я удалю volatile из one.cpp, код станет медленнее. Почему это?

Более крупные инструкции нажимают последнюю инструкцию в цикле через границу 32B:

 59e:   83 eb 01                sub    ebx,0x1
 5a1:   75 dd                   jne    580 <main+0x20>

Итак, если вы не работаете из буфера цикла (LSD), то без volatile один из циклов выборки uop-cache получает только 1 uop.

Если sub/jne макро-предохранители, это может не примениться. И я думаю, что только пересечение границы 64B нарушит макро-слияние.

Кроме того, это не настоящие адреса. Вы проверили, какие адреса после связывания? Там может быть граница 64B после компоновки, если текстовая секция имеет выравнивание менее 64B.


Извините, я на самом деле не проверял это, чтобы сказать больше об этом конкретном случае. Дело в том, что, когда вы сталкиваетесь с интерфейсом на стороне, например, с call/ret внутри жесткой петли, выравнивание становится важным и может стать чрезвычайно сложным. Пограничное пересечение или нет для всех будущих инструкций. Не ожидайте, что это будет просто. Если вы прочтете мои другие ответы, вы поймете, что я обычно не такой человек, чтобы сказать "это слишком сложно, чтобы полностью объяснить", но выравнивание может быть таким.

См. также Выравнивание кода в одном объектном файле влияет на производительность функции в другом объектном файле

В вашем случае убедитесь, что крошечные функции встроены. Используйте оптимизацию времени соединения, если ваша кодовая база имеет какие-либо важные крошечные функции в отдельных файлах .c, а не в .h, где они могут встроить. Или измените свой код, чтобы поместить их в .h.