Por que esse código SSE é 6 vezes mais lento sem o VZEROUPPER no Skylake?

Eu tenho tentado descobrir um problema de desempenho em um aplicativo e finalmente reduzi-lo a um problema muito estranho. A parte de código a seguir é executada 6 vezes mais lentamente em uma CPU Skylake (i5-6500) se a instrução VZEROUPPER estiver comentado. Testei os CPUs Sandy Bridge e Ivy Bridge e as duas versões rodam na mesma velocidade, com ou sem o VZEROUPPER .

Agora eu tenho uma boa idéia do que o VZEROUPPER faz e eu acho que não deveria importar em nada para este código quando não há instruções codificadas VEX e nenhuma chamada para qualquer function que possa contê-las. O fato de que isso não acontece em outros processadores compatíveis com o AVX parece suportar isso. O mesmo acontece com a tabela 11-2 do Manual de Referência de Otimização de Arquiteturas Intel® 64 e IA-32.

Então, o que está acontecendo?

A única teoria que me resta é que há um bug na CPU e está acionando incorretamente o procedimento “salvar a metade superior do registrador AVX”, onde não deveria. Ou algo mais tão estranho.

Isso é 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; } 

e esta é 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; } 

A function compila isso com 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 

O código gerado é diferente do gcc, mas mostra o mesmo problema. Uma versão mais antiga do compilador Intel gera ainda outra variação da function que mostra o problema também, mas somente se main.cpp não for construído com o compilador Intel, pois ele insere chamadas para inicializar algumas de suas próprias bibliotecas, que provavelmente acabam fazendo VZEROUPPER algum lugar. .

E, claro, se a coisa toda for construída com suporte a AVX, de modo que os intrínsecos sejam transformados em instruções codificadas VEX, também não há problema.

Eu tentei profiling o código com perf em linux ea maioria do tempo de execução geralmente chega em 1-2 instruções, mas nem sempre as mesmas, dependendo de qual versão do código eu perfil (gcc, clang, intel). Encurtar a function parece fazer com que a diferença de desempenho desapareça gradualmente, de modo que parece que várias instruções estão causando o problema.

EDIT: Aqui está uma versão de assembly pura, para o Linux. Comentários abaixo.

  .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, então, como suspeito nos comentários, o uso de instruções codificadas VEX causa a lentidão. Usando VZEROUPPER apaga tudo. Mas isso ainda não explica por quê.

Pelo que entendi, não usar VZEROUPPER deve envolver um custo para a transição para instruções SSE antigas, mas não uma desaceleração permanente delas. Especialmente não tão grande. Levando em conta a sobrecarga de loop, a proporção é de pelo menos 10x, talvez mais.

Eu tentei mexer um pouco na assembly e as instruções flutuantes são tão ruins quanto as duplas. Também não consegui identificar o problema para uma única instrução.

Você está sofrendo uma penalidade por “misturar” instruções não VEX SSE e VEX-codificadas – mesmo que todo o seu aplicativo visível obviamente não use nenhuma instrução AVX! . Antes do Skylake, esse tipo de penalidade era apenas uma penalidade de transição única, ao mudar de código que usava vex para código que não funcionava, ou vice-versa. Ou seja, você nunca pagou uma multa em curso pelo que aconteceu no passado, a menos que estivesse ativamente misturando VEX e não-VEX. Em Skylake, no entanto, há um estado em que as instruções não VEX SSE pagam uma alta penalidade de execução contínua, mesmo sem mais misturas.

Diretamente da boca do cavalo, veja a Figura 11-1 1 – o antigo diagrama de transição (pré-Skylake):

Penalidades de transição pré-skylake

Como você pode ver, todas as penalidades (setas vermelhas) levam você a um novo estado, e nesse ponto não há mais uma penalidade por repetir essa ação. Por exemplo, se você chegar ao estado superior sujo executando um AVX de 256 bits e executar o SSE herdado, pagará uma penalidade única para fazer a transição para o estado superior não-INIT preservado , mas não pagará quaisquer penalidades depois disso.

Em Skylake, tudo é diferente conforme a Figura 11-2 :

Penalidades de Skylake

Existem menos penalidades no geral, mas, criticamente, para o seu caso, uma delas é um auto-loop: a penalidade para executar uma instrução SSE legada (no modo suja superior) mantém você nesse estado. Isso é o que acontece com você – qualquer instrução AVX coloca você no estado superior sujo, o que atrasa toda a execução do SSE.

Veja o que a Intel diz (seção 11.3) sobre a nova penalidade:

A microarquitetura Skylake implementa uma máquina de estados diferente das gerações anteriores para gerenciar a transição de estado YMM associada à mistura de instruções SSE e AVX. Ele não salva mais todo o estado YMM superior ao executar uma instrução SSE quando estiver no estado “Modificado e Não Salvo”, mas salva os bits superiores do registro individual. Como resultado, as instruções SSE e AVX de mistura terão uma penalidade associada à dependência de registro parcial dos registros de destino sendo usados ​​e operação de mesclagem adicional nos bits superiores dos registros de destino.

Portanto, a penalidade é aparentemente muito grande – ela tem que misturar os bits superiores o tempo todo para preservá-los, e também faz instruções que aparentemente são independentemente dependentes, já que há uma dependência dos bits superiores ocultos. Por exemplo, xorpd xmm0, xmm0 não quebra mais a dependência do valor anterior de xmm0 , uma vez que o resultado é realmente dependente dos bits superiores escondidos de ymm0 que não são limpos pelo xorpd . Esse último efeito é provavelmente o que mata seu desempenho, já que agora você terá longas cadeias de dependencies que não esperariam da análise usual.

Esse é um dos piores tipos de desempenho: onde o comportamento / melhor prática para a arquitetura anterior é essencialmente oposto à arquitetura atual. Presumivelmente, os arquitetos de hardware tinham um bom motivo para fazer a mudança, mas apenas adicionaram outra “pegadinha” à lista de problemas sutis de desempenho.

Eu arquivaria um bug contra o compilador ou o tempo de execução que inseriu essa instrução AVX e não seguiu com um VZEROUPPER .

Atualização: Conforme o comentário do OP abaixo, o código ofensivo (AVX) foi inserido pelo vinculador de tempo de execução ld e um bug já existe.


1 Do manual de otimização da Intel.

Acabei de fazer algumas experiências (em um Haswell). A transição entre estados limpos e sujos não é cara, mas o estado sujo faz com que toda operação vetorial não-VEX dependa do valor anterior do registro de destino. No seu caso, por exemplo, movapd %xmm1, %xmm5 terá uma falsa dependência em ymm5, o que impede a execução fora de ordem. Isso explica por que o vzeroupper é necessário após o código AVX.