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.