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

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

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

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

Единственная теория, которую я оставил, заключается в том, что в процессоре есть ошибка, и она неправильно запускает процедуру "сохранить верхнюю половину регистров 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). Укорочение функции, по-видимому, постепенно устраняет разницу в производительности, поэтому похоже, что некоторые инструкции вызывают проблему.

РЕДАКТИРОВАТЬ: Вот чистая версия сборки, для 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, но не приведет к их постоянному замедлению. Особенно не такой большой. Принимая во внимание издержки цикла, это соотношение должно быть не менее 10х, а может быть и больше.

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

2 ответа

Решение

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

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

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

Штрафы за переходный период до Skylake

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

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

Скайлэйк Штрафы

В целом меньше штрафов, но критически важно для вашего случая, одно из них - самоконтроль: штраф за выполнение устаревшей инструкции 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.

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

Другие вопросы по тегам