Почему этот код SSE в 6 раз медленнее без VZEROUPPER на Skylake?

Я пытался выяснить проблему производительности в приложении и, наконец, сузил ее до действительно странной проблемы. Следующий фрагмент кода работает в 6 раз медленнее на процессоре Skylake (i5-6500), если команда VZEROUPPER закомментирована. Я тестировал процессоры Sandy Bridge и Ivy Bridge, и обе версии работают с одинаковой скоростью, с или без VZEROUPPER.

Теперь у меня неплохое представление о том, что делает VZEROUPPER, и я думаю, что это не должно иметь никакого значения для этого кода, когда нет кодированных инструкций VEX и нет вызовов какой-либо функции, которая может их содержать. Тот факт, что он не поддерживает другие процессоры, совместимые с AVX, похоже, поддерживает это. Так же, как и таблица 11-2 в Справочное руководство по оптимизации архитектуры Intel® 64 и IA-32

Так что происходит?

Единственная теория, которую я оставил, состоит в том, что в CPU есть ошибка, и она неправильно запускает процедуру "сохранить верхнюю половину регистров AVX", где она не должна. Или что-то еще так же странно.

Это main.cpp:

#include <immintrin.h>

int slow_function( double i_a, double i_b, double i_c );

int main()
{
    /* DAZ and FTZ, does not change anything here. */
    _mm_setcsr( _mm_getcsr() | 0x8040 );

    /* This instruction fixes performance. */
    __asm__ __volatile__ ( "vzeroupper" : : : );

    int r = 0;
    for( unsigned j = 0; j < 100000000; ++j )
    {
        r |= slow_function( 
                0.84445079384884236262,
                -6.1000481519580951328,
                5.0302160279288017364 );
    }
    return r;
}

и это slow_function.cpp:

#include <immintrin.h>

int slow_function( double i_a, double i_b, double i_c )
{
    __m128d sign_bit = _mm_set_sd( -0.0 );
    __m128d q_a = _mm_set_sd( i_a );
    __m128d q_b = _mm_set_sd( i_b );
    __m128d q_c = _mm_set_sd( i_c );

    int vmask;
    const __m128d zero = _mm_setzero_pd();

    __m128d q_abc = _mm_add_sd( _mm_add_sd( q_a, q_b ), q_c );

    if( _mm_comigt_sd( q_c, zero ) && _mm_comigt_sd( q_abc, zero )  )
    {
        return 7;
    }

    __m128d discr = _mm_sub_sd(
        _mm_mul_sd( q_b, q_b ),
        _mm_mul_sd( _mm_mul_sd( q_a, q_c ), _mm_set_sd( 4.0 ) ) );

    __m128d sqrt_discr = _mm_sqrt_sd( discr, discr );
    __m128d q = sqrt_discr;
    __m128d v = _mm_div_pd(
        _mm_shuffle_pd( q, q_c, _MM_SHUFFLE2( 0, 0 ) ),
        _mm_shuffle_pd( q_a, q, _MM_SHUFFLE2( 0, 0 ) ) );
    vmask = _mm_movemask_pd(
        _mm_and_pd(
            _mm_cmplt_pd( zero, v ),
            _mm_cmple_pd( v, _mm_set1_pd( 1.0 ) ) ) );

    return vmask + 1;
}

Функция компилируется до этого с помощью clang:

 0:   f3 0f 7e e2             movq   %xmm2,%xmm4
 4:   66 0f 57 db             xorpd  %xmm3,%xmm3
 8:   66 0f 2f e3             comisd %xmm3,%xmm4
 c:   76 17                   jbe    25 <_Z13slow_functionddd+0x25>
 e:   66 0f 28 e9             movapd %xmm1,%xmm5
12:   f2 0f 58 e8             addsd  %xmm0,%xmm5
16:   f2 0f 58 ea             addsd  %xmm2,%xmm5
1a:   66 0f 2f eb             comisd %xmm3,%xmm5
1e:   b8 07 00 00 00          mov    $0x7,%eax
23:   77 48                   ja     6d <_Z13slow_functionddd+0x6d>
25:   f2 0f 59 c9             mulsd  %xmm1,%xmm1
29:   66 0f 28 e8             movapd %xmm0,%xmm5
2d:   f2 0f 59 2d 00 00 00    mulsd  0x0(%rip),%xmm5        # 35 <_Z13slow_functionddd+0x35>
34:   00 
35:   f2 0f 59 ea             mulsd  %xmm2,%xmm5
39:   f2 0f 58 e9             addsd  %xmm1,%xmm5
3d:   f3 0f 7e cd             movq   %xmm5,%xmm1
41:   f2 0f 51 c9             sqrtsd %xmm1,%xmm1
45:   f3 0f 7e c9             movq   %xmm1,%xmm1
49:   66 0f 14 c1             unpcklpd %xmm1,%xmm0
4d:   66 0f 14 cc             unpcklpd %xmm4,%xmm1
51:   66 0f 5e c8             divpd  %xmm0,%xmm1
55:   66 0f c2 d9 01          cmpltpd %xmm1,%xmm3
5a:   66 0f c2 0d 00 00 00    cmplepd 0x0(%rip),%xmm1        # 63 <_Z13slow_functionddd+0x63>
61:   00 02 
63:   66 0f 54 cb             andpd  %xmm3,%xmm1
67:   66 0f 50 c1             movmskpd %xmm1,%eax
6b:   ff c0                   inc    %eax
6d:   c3                      retq   

Сгенерированный код отличается от gcc, но он показывает ту же проблему. Более старая версия компилятора Intel генерирует еще одну вариацию функции, которая также показывает проблему, но только если main.cpp не построена с помощью компилятора Intel, поскольку она вставляет вызовы для инициализации некоторых своих собственных библиотек, которые, вероятно, в конечном итоге выполняют VZEROUPPER где-то.

И, конечно, если все это построено с поддержкой AVX, поэтому встроенные средства превращаются в кодированные команды VEX, также нет проблем.

Я пробовал профилировать код с помощью perf в linux, и большая часть времени выполнения обычно приземляется на 1-2 команды, но не всегда одни и те же, в зависимости от версии версии кода я (gcc, clang, intel), Ускорение функции означает, что разница в производительности постепенно исчезает, поэтому похоже, что некоторые инструкции вызывают проблему.

EDIT: Здесь доступна чистая версия сборки для linux. Комментарии ниже.

    .text
    .p2align    4, 0x90
    .globl _start
_start:

    #vmovaps %ymm0, %ymm1  # This makes SSE code crawl.
    #vzeroupper            # This makes it fast again.

    movl    $100000000, %ebp
    .p2align    4, 0x90
.LBB0_1:
    xorpd   %xmm0, %xmm0
    xorpd   %xmm1, %xmm1
    xorpd   %xmm2, %xmm2

    movq    %xmm2, %xmm4
    xorpd   %xmm3, %xmm3
    movapd  %xmm1, %xmm5
    addsd   %xmm0, %xmm5
    addsd   %xmm2, %xmm5
    mulsd   %xmm1, %xmm1
    movapd  %xmm0, %xmm5
    mulsd   %xmm2, %xmm5
    addsd   %xmm1, %xmm5
    movq    %xmm5, %xmm1
    sqrtsd  %xmm1, %xmm1
    movq    %xmm1, %xmm1
    unpcklpd    %xmm1, %xmm0
    unpcklpd    %xmm4, %xmm1

    decl    %ebp
    jne    .LBB0_1

    mov $0x1, %eax
    int $0x80

Хорошо, так как, как подозревали в комментариях, использование кодированных инструкций VEX приводит к замедлению. Использование VZEROUPPER очищает его. Но это все еще не объясняет, почему.

Как я понимаю, использование VZEROUPPER подразумевает затраты на переход к старым инструкциям SSE, но не постоянное их замедление. Особенно не такой большой. Принимая во внимание накладные расходы на цикл, отношение составляет не менее 10x, возможно, больше.

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

Ответ 1

Вы получаете штраф за "смешивание" не-VEX SSE и VEX-закодированных инструкций - даже если все ваше видимое приложение явно не использует инструкции AVX!

До Skylake этот тип штрафа был только однократным штрафом за переход при переключении с кода, который использовал vex, на код, который не использовал, или наоборот. То есть вы никогда не платили постоянный штраф за то, что происходило в прошлом, если вы не активно смешивали VEX и не VEX. В Skylake, однако, существует состояние, когда инструкции SSE, не относящиеся к VEX, платят высокий штраф за постоянное выполнение, даже без дополнительного микширования.

Прямо из пасти лошади, здесь Рисунок 11-1 1 - старая (до Skylake) диаграмма перехода:

Pre-Skylake Transition Penalties

Как видите, все штрафы (красные стрелки) переносят вас в новое состояние, и в этот момент больше нет штрафа за повторение этого действия. Например, если вы попали в грязное верхнее состояние, выполнив 256-битный AVX, а затем выполнили устаревший SSE, вы платите единовременный штраф за переход в сохраненное верхнее состояние, отличное от INIT, но не платите любые штрафы после этого.

В Skylake все по-другому, как показано на рисунке 11-2:

Skylake Penalties

В целом, штрафов меньше, но, что критично для вашего случая, одним из них является самоконтроль: штраф за выполнение устаревшей инструкции SSE (штраф А на рисунке 11-2) в грязном верхнем состоянии удерживает вас в этом состоянии. Вот что с вами происходит - любая инструкция AVX переводит вас в грязное верхнее состояние, что замедляет все дальнейшее выполнение SSE.

Вот что говорит Intel (раздел 11.3) о новом наказании:

Микроархитектура Skylake реализует конечный автомат, отличный от предыдущих поколений, для управления переходом состояний YMM, связанным со смешением команд SSE и AVX. Он больше не сохраняет все верхнее состояние YMM при выполнении инструкции SSE, когда находится в состоянии "Модифицированный и несохраненный", но сохраняет верхние биты отдельного регистра. В результате смешивание команд SSE и AVX будет подвергаться штрафу, связанному с частичной зависимостью от регистра используемых регистров назначения и дополнительной операцией смешения с старшими битами регистров назначения.

Таким образом, штраф, по-видимому, довольно велик - он должен постоянно смешивать верхние биты, чтобы сохранить их, и он также делает инструкции, которые, по-видимому, независимо становятся зависимыми, поскольку существует зависимость от скрытых старших битов. Например, xorpd xmm0, xmm0 больше не нарушает зависимость от предыдущего значения xmm0, поскольку результат фактически зависит от скрытых старших битов из ymm0 которые не очищаются xorpd. Этот последний эффект, вероятно, и убивает вашу производительность, поскольку теперь у вас будут очень длинные цепочки зависимостей, которые не ожидаются при обычном анализе.

Это один из наихудших ошибок в производительности: где поведение/лучшие практики для предыдущей архитектуры по существу противоположны текущей архитектуре. Предположительно, у аппаратных архитекторов была хорошая причина для внесения изменений, но это просто добавило еще одну "ошибку" в список тонких проблем с производительностью.

Я бы подал сообщение об ошибке в компиляторе или во время выполнения, которое вставляло эту инструкцию AVX и не следовало за VZEROUPPER.

Обновление: в соответствии с комментарием OP ниже, код ошибки (AVX) был вставлен компоновщиком времени выполнения ld и ошибка уже существует.


1 Из руководства по оптимизации Intel.

Ответ 2

Я только что провел несколько экспериментов (на Haswell). Переход между чистым и грязным состояниями не дорогой, но грязное состояние делает каждую векторную операцию не-VEX зависимой от предыдущего значения регистра назначения. В вашем случае, например, movapd %xmm1, %xmm5 будет иметь ложную зависимость от ymm5 которая предотвращает выполнение не по порядку. Это объясняет, почему vzeroupper необходим после кода AVX.