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

He estado tratando de descubrir un problema de rendimiento en una aplicación y finalmente lo reduje 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 la instrucción VZEROUPPER está comentada. VZEROUPPER CPU Sandy Bridge e Ivy Bridge y ambas versiones funcionan a la misma velocidad, con o sin VZEROUPPER .

Ahora tengo una idea bastante buena de lo que VZEROUPPER hace y creo que no debería importar en absoluto a este código cuando no hay instrucciones codificadas VEX ni llamadas a ninguna función que pueda contenerlas. El hecho de que no funciona en otras CPU con capacidad AVX parece ser compatible. Lo mismo ocurre con la tabla 11-2 en Intel® 64 y IA-32 Architectures Optimization Reference Manual

¿Entonces qué está pasando?

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

Esto es main.cpp:

 #include  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  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 se comstack a 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  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  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  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  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 comstackdor de 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 comstackdor de inteligencia ya que inserta llamadas para inicializar algunas de sus propias bibliotecas que probablemente terminen haciendo VZEROUPPER algún lugar .

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

Intenté perfilar el código con perf en Linux y la mayor parte del tiempo de ejecución usualmente se basa en 1-2 instrucciones, pero no siempre las mismas, dependiendo de la versión del código del perfil I (gcc, clang, intel). El acortamiento de la función parece hacer que la diferencia de rendimiento desaparezca gradualmente, por lo que parece que varias instrucciones están causando el problema.

EDITAR: Aquí hay una versión de ensamblaje puro, 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, entonces, como se sospecha en los comentarios, el uso de instrucciones codificadas VEX causa la desaceleración. El uso de VZEROUPPER borra. Pero eso todavía no explica por qué.

Según tengo entendido, no usar VZEROUPPER implica un costo de transición a las antiguas instrucciones de SSE, pero no una desaceleración permanente de ellas. Especialmente no tan grande. Teniendo en cuenta los gastos generales del ciclo, la proporción es de al menos 10x, quizás más.

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

Está experimentando una penalización por “mezclar” instrucciones que no son VEX SSE y VEX-encoded, ¡ aunque su aplicación visible completa obviamente no usa ninguna instrucción AVX! . Antes de Skylake, este tipo de penalización solo era una penalización de transición de una sola vez, cuando se cambiaba de código que usaba vex a código que no lo hacía, o viceversa. Es decir, nunca pagaste una penalización en curso por lo que sucedió en el pasado a menos que estuvieras mezclando activamente VEX y no VEX. En Skylake, sin embargo, existe un estado en el que las instrucciones de SSE que no son de VEX pagan una alta penalización de ejecución en curso, incluso sin una mayor mezcla.

Directamente de la boca del caballo, aquí está la Figura 11-1 1 – el diagtwig de transición anterior (anterior a Skylake):

Sanciones de transición Pre-Skylake

Como puede ver, todas las penalizaciones (flechas rojas) lo llevan a un nuevo estado, en cuyo punto ya no existe una penalización por repetir esa acción. Por ejemplo, si llega al estado superior sucio ejecutando un AVX de 256 bits, entonces ejecuta el SSE heredado, paga una penalización única para pasar al estado superior no INIT conservado , pero no paga cualquier penalización después de eso.

En Skylake, todo es diferente según la Figura 11-2 :

Sanciones de Skylake

Hay menos sanciones en general, pero críticamente para su caso, una de ellas es un autoenlace: la penalización por ejecutar una instrucción SSE heredada ( Penalidad A en la Figura 11-2) en el estado superior sucio lo mantiene en ese estado. Eso es lo que le sucede a usted: cualquier instrucción AVX lo coloca en el estado superior sucio, lo que ralentiza la ejecución de SSE.

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

La microarchitecture Skylake implementa una máquina de estado diferente a las generaciones anteriores para administrar la transición de estado YMM asociada con la mezcla de instrucciones SSE y AVX. Ya no guarda todo el estado YMM superior cuando ejecuta una instrucción SSE cuando está en estado “Modificado y no guardado”, pero guarda los bits superiores del registro individual. Como resultado, la mezcla de instrucciones SSE y AVX experimentará una penalización asociada con la dependencia de registro parcial de los registros de destino que se utilizan y una operación de mezcla adicional en los bits superiores de los registros de destino.

Por lo tanto, la penalización es aparentemente bastante grande: tiene que mezclar los bits superiores todo el tiempo para conservarlos, y también hace que las instrucciones que aparentemente son independientes se vuelvan dependientes, ya que existe 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 de los bits superiores ocultos de ymm0 que no son borrados por el xorpd . Este último efecto es probablemente lo que mata su rendimiento ya que ahora tendrá cadenas de dependencia muy largas que no esperarían del análisis habitual.

Este es uno de los peores riesgos de desempeño: donde el comportamiento / mejores prácticas para la architecture anterior es esencialmente opuesto a la architecture actual. Es de suponer que 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.

Me gustaría presentar un error contra el comstackdor o el tiempo de ejecución que insertó esa instrucción AVX y no siguió con un VZEROUPPER .

Actualización: según el comentario de OP a continuación, el código del enrutador ld insertó el código ofensor (AVX) y ya existe un error .


1 Del manual de optimización de Intel.

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