¿Por qué es este código SSE 6 veces más lento sin VZEROUPPER en Skylake?


He estado tratando de averiguar un problema de rendimiento en una aplicación y finalmente lo he reducido a un problema realmente extraño. El siguiente fragmento de código se ejecuta 6 veces más lento en una CPU Skylake (i5-6500) si se comenta la instrucción VZEROUPPER. He probado las CPU Sandy Bridge y Ivy Bridge y ambas versiones funcionan a la misma velocidad, con o sin VZEROUPPER.

Ahora tengo una idea bastante buena de lo que hace VZEROUPPER y creo que no debería importar en absoluto a este código cuando hay no hay instrucciones codificadas VEX y no hay llamadas a ninguna función que pueda contenerlas. El hecho de que no lo haga en otras CPU compatibles con AVX parece apoyar esto. También lo hace la tabla 11-2 del Manual de Referencia de Optimización de Arquitecturas Intel® 64 e IA-32

Entonces, ¿qué está pasando?

La única teoría que me queda es que hay un error en la CPU y está activando incorrectamente el procedimiento "guardar la mitad superior de los registros AVX" donde no debería. igual de extraño.

Esto es principal.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;
}

Y esto es 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;
}

La función compila esto con 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   

El código generado es diferente con gcc pero muestra el mismo problema. Una versión anterior del compilador intel genera otra variación de la función que también muestra el problema, pero solo si main.cpp no está construido con el compilador intel, ya que inserta llamadas para inicializar algunas de sus propias bibliotecas que probablemente terminen haciendo VZEROUPPER en alguna parte.

Y por supuesto, si todo está construido con soporte AVX para que los intrínsecos se conviertan en instrucciones codificadas VEX, tampoco hay problema.

He intentado perfilar el código con perf en linux y la mayor parte del tiempo de ejecución generalmente cae en 1-2 instrucciones, pero no siempre las mismas dependiendo de qué versión del código I perfil (gcc, clang, intel). Acortar la función parece hacer que la diferencia de rendimiento vaya gradualmente lejos así que parece que varias instrucciones están causando el problema.

EDITAR: Aquí hay una versión ensamblada pura, para linux. Comentarios a continuación.

    .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

Ok, así como se sospecha en los comentarios, el uso de instrucciones codificadas VEX causa la desaceleración. Usando VZEROUPPER lo aclara. Pero eso todavía no explica por qué.

Según lo entiendo, no usar VZEROUPPER se supone que implica un costo para la transición a las viejas instrucciones de la ESS, pero no una desaceleración permanente de ellas. Especialmente no tan grande una. Teniendo en cuenta la sobrecarga del bucle, la relación es al menos 10x, quizás más.

He intentado jugar con el montaje un poco y las instrucciones de flotación son tan malas como las dobles. Tampoco pude identificar el problema con una sola instrucción.

Author: Paul R, 2016-12-23

2 answers

Usted está experimentando una penalización por "mezclar" SSE no VEX e instrucciones codificadas VEX- a pesar de que toda su aplicación visible obviamente no utiliza ninguna instrucción AVX!. Antes de Skylake, este tipo de penalización era solo una penalización de transición , al cambiar de código que usaba vex a código que no lo hacía, o viceversa. Es decir, nunca pagaste una penalización continua por lo que sucedió en el pasado a menos que estuvieras mezclando activamente VEX y no VEX. En Skylake, sin embargo, hay un estado donde las instrucciones SSE no VEX pagan una alta penalización de ejecución continua, incluso sin más mezclas.

Directamente de la boca del caballo, aquí está Figura 11-1 1 - el viejo diagrama de transición (pre-Skylake):

Sanciones de Transición Antes de Skylake

Como puedes ver, todas las penalizaciones (flechas rojas), te llevan a un nuevo estado, momento en el que ya no hay penalizaciones por repetir esa acción. Por ejemplo, si se llega a la sucio estado superior al ejecutar algún AVX de 256 bits, y luego ejecutar SSE heredado, paga una penalización de una sola vez para la transición al estado superior no INIT preservado, pero no paga ninguna penalización después de eso.

En Skylake, todo es diferente por Figura 11-2:

Sanciones Skylake

Hay menos penalizaciones en general, pero críticamente para su caso, una de ellas es un auto-bucle: la penalidad por ejecutar un SSE heredado (Penalidad A en la Figura 11-2) la instrucción en el estado dirty upper te mantiene en ese estado. Eso es lo que te sucede: cualquier instrucción AVX te pone en el estado superior sucio, lo que ralentiza toda la ejecución posterior de SSE.

Esto es lo que Intel dice (sección 11.3) sobre la nueva penalización:

La microarquitectura Skylake implementa una máquina de estados diferente que las generaciones anteriores para gestionar la transición de estado YMM asociado con instrucciones de mezcla SSE y AVX. No más tiempo guarda toda la estado YMM superior cuando se ejecuta una instrucción SSE cuando está en " Modificado y Unsaved " estado, pero guarda los bits superiores del registro individual. Como resultado, mezclar instrucciones de SSE y AVX experimentará una penalización asociado a una dependencia parcial del registro del destino registros en uso y operación de mezcla adicional en los bits superiores de los registros de destino.

Así que la pena es aparentemente bastante grande-tiene que mezclar los bits superiores todo el tiempo para preservarlos, y también hace que las instrucciones que aparentemente son independientes se vuelvan dependientes, ya que hay una dependencia de los bits superiores ocultos. Por ejemplo, xorpd xmm0, xmm0 ya no rompe la dependencia del valor anterior de xmm0, ya que el resultado depende en realidad de los bits superiores ocultos de ymm0 que no se borran con xorpd. Ese último efecto es probablemente lo que mata su rendimiento, ya que ahora tendrá cadenas de dependencias muy largas que no esperaría de el análisis habitual.

Este es uno de los peores tipos de trampas de rendimiento: donde el comportamiento/las mejores prácticas para la arquitectura anterior es esencialmente opuesto a la arquitectura actual. Presumiblemente, los arquitectos de hardware tenían una buena razón para hacer el cambio, pero simplemente agrega otro "gotcha" a la lista de problemas sutiles de rendimiento.

Archivaría un bug contra el compilador o tiempo de ejecución que insertara esa instrucción AVX y no siguiera con un VZEROUPPER.

Actualización: Según el comentario de la OP a continuación, el código ofensivo (AVX) fue insertado por el enlazador de tiempo de ejecución ld y ya existe un error .


1 Del manual de optimización de Intel.

 30
Author: BeeOnRope,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-05-23 12:34:27

Acabo de hacer algunos experimentos (en un Haswell). La transición entre los estados limpio y sucio no es costosa, pero el estado sucio hace que cada operación de vector no VEX dependa del valor anterior del registro de destino. En su caso, por ejemplo movapd %xmm1, %xmm5 tendrá una dependencia falsa en ymm5 que impide la ejecución fuera de orden. Esto explica por qué se necesita vzeroupper después del código AVX.

 11
Author: A Fog,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2016-12-29 02:26:52