performance x86 intel sse avx

performance - avx intel



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

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 que no sea VEX dependa del valor anterior del registro de destino. En su caso, por ejemplo, movapd %xmm1, %xmm5 tendrá una dependencia falsa de ymm5 que evita la ejecución fuera de orden. Esto explica por vzeroupper se necesita vzeroupper después del código AVX.

He estado tratando de resolver un problema de rendimiento en una aplicación y finalmente lo reduje a un problema realmente extraño. El siguiente código se ejecuta 6 veces más lento en una CPU Skylake (i5-6500) si se VZEROUPPER instrucción VZEROUPPER . He probado las 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 hace VZEROUPPER y creo que no debería importar en absoluto este código cuando 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 con capacidad AVX parece respaldar esto. Lo mismo ocurre con la tabla 11-2 en el 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. O algo más igual de extraño.

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

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 se compila en 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 de inteligencia genera otra variación de la función que también muestra el problema, pero solo si main.cpp no se construye con el compilador de inteligencia, ya que inserta llamadas para inicializar algunas de sus propias bibliotecas que probablemente terminen haciendo VZEROUPPER 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 recibe 1-2 instrucciones, pero no siempre las mismas dependiendo de la versión del código que perfilo (gcc, clang, intel). Acortar 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 abajo.

.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, como se sospecha en los comentarios, el uso de instrucciones codificadas VEX causa la desaceleración. Usando VZEROUPPER aclara. Pero eso todavía no explica por qué.

Según tengo entendido, se supone que no usar VZEROUPPER implica un costo para la transición a las instrucciones SSE antiguas, pero no una desaceleración permanente de las mismas. Especialmente no tan grande. Teniendo en cuenta la sobrecarga del bucle, la relación es al menos 10x, tal vez más.

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


Está experimentando una penalización por "mezclar" instrucciones que no sean VEX SSE y codificadas por VEX, ¡ aunque toda su aplicación visible obviamente no usa ninguna instrucción AVX!

Antes de Skylake, este tipo de penalización era solo una penalización de transición única, cuando se cambiaba de código que usaba vex a código que no lo hacía, o viceversa. Es decir, nunca pagó una multa continua por lo que sucedió en el pasado a menos que estuviera mezclando activamente VEX y no VEX. En Skylake, sin embargo, hay un estado en el que las instrucciones SSE que no son VEX pagan una alta penalización de ejecución continua, incluso sin más mezcla.

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

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

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

Hay menos penalizaciones en general, pero críticamente para su caso, una de ellas es un bucle automático: la penalización por ejecutar una instrucción SSE ( Penalización A en la Figura 11-2) heredada en el estado superior sucio lo 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 de SSE.

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

La microarquitectura Skylake implementa una máquina de estados 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 superior de YMM cuando ejecuta una instrucción SSE cuando está en estado "Modificado y sin guardar", pero guarda los bits superiores del registro individual. Como resultado, la combinación de instrucciones SSE y AVX experimentará una penalización asociada con la dependencia parcial del registro de los registros de destino que se utilizan y la operación de mezcla adicional en los bits superiores de los registros de destino.

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

Este es uno de los peores problemas de rendimiento: donde el comportamiento / mejor práctica 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 solo agrega otro "problema" a la lista de problemas sutiles de rendimiento.

VZEROUPPER un error contra el compilador o el tiempo de ejecución que insertó esa instrucción AVX y no siguió con un VZEROUPPER .

Actualización: Según el comment del OP a continuación, el código de ejecución ld insertó el código ofensivo (AVX) y ya existe un bug .

1 Del manual de optimización de Intel.