Por que a mudança de 0,1f para 0 reduz o desempenho em 10x?

Por que esse pedaço de código,

const float x[16] = { 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6}; const float z[16] = {1.123, 1.234, 1.345, 156.467, 1.578, 1.689, 1.790, 1.812, 1.923, 2.034, 2.145, 2.256, 2.367, 2.478, 2.589, 2.690}; float y[16]; for (int i = 0; i < 16; i++) { y[i] = x[i]; } for (int j = 0; j < 9000000; j++) { for (int i = 0; i < 16; i++) { y[i] *= x[i]; y[i] /= z[i]; y[i] = y[i] + 0.1f; // <-- y[i] = y[i] - 0.1f; // <-- } } 

correr mais de 10 vezes mais rápido que o bit seguinte (idêntico exceto onde indicado)?

 const float x[16] = { 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6}; const float z[16] = {1.123, 1.234, 1.345, 156.467, 1.578, 1.689, 1.790, 1.812, 1.923, 2.034, 2.145, 2.256, 2.367, 2.478, 2.589, 2.690}; float y[16]; for (int i = 0; i < 16; i++) { y[i] = x[i]; } for (int j = 0; j < 9000000; j++) { for (int i = 0; i < 16; i++) { y[i] *= x[i]; y[i] /= z[i]; y[i] = y[i] + 0; // <-- y[i] = y[i] - 0; // <-- } } 

ao compilar com o Visual Studio 2010 SP1. (Eu não testei com outros compiladores.)

Bem-vindo ao mundo do ponto flutuante desnormalizado ! Eles podem causar estragos no desempenho !!!

Os números de Denormal (ou subnormal) são uma espécie de hack para obter valores muito próximos de zero da representação de ponto flutuante. As operações em ponto flutuante desnormalizado podem ser de dezenas a centenas de vezes mais lentas do que em ponto flutuante normalizado. Isso ocorre porque muitos processadores não podem manipulá-los diretamente e devem interceptá-los e resolvê-los usando o microcódigo.

Se você imprimir os números após 10.000 iterações, verá que eles convergiram para valores diferentes, dependendo se 0 ou 0.1 for usado.

Aqui está o código de teste compilado em x64:

 int main() { double start = omp_get_wtime(); const float x[16]={1.1,1.2,1.3,1.4,1.5,1.6,1.7,1.8,1.9,2.0,2.1,2.2,2.3,2.4,2.5,2.6}; const float z[16]={1.123,1.234,1.345,156.467,1.578,1.689,1.790,1.812,1.923,2.034,2.145,2.256,2.367,2.478,2.589,2.690}; float y[16]; for(int i=0;i<16;i++) { y[i]=x[i]; } for(int j=0;j<9000000;j++) { for(int i=0;i<16;i++) { y[i]*=x[i]; y[i]/=z[i]; #ifdef FLOATING y[i]=y[i]+0.1f; y[i]=y[i]-0.1f; #else y[i]=y[i]+0; y[i]=y[i]-0; #endif if (j > 10000) cout << y[i] << " "; } if (j > 10000) cout << endl; } double end = omp_get_wtime(); cout << end - start << endl; system("pause"); return 0; } 

Saída:

 #define FLOATING 1.78814e-007 1.3411e-007 1.04308e-007 0 7.45058e-008 6.70552e-008 6.70552e-008 5.58794e-007 3.05474e-007 2.16067e-007 1.71363e-007 1.49012e-007 1.2666e-007 1.11759e-007 1.04308e-007 1.04308e-007 1.78814e-007 1.3411e-007 1.04308e-007 0 7.45058e-008 6.70552e-008 6.70552e-008 5.58794e-007 3.05474e-007 2.16067e-007 1.71363e-007 1.49012e-007 1.2666e-007 1.11759e-007 1.04308e-007 1.04308e-007 //#define FLOATING 6.30584e-044 3.92364e-044 3.08286e-044 0 1.82169e-044 1.54143e-044 2.10195e-044 2.46842e-029 7.56701e-044 4.06377e-044 3.92364e-044 3.22299e-044 3.08286e-044 2.66247e-044 2.66247e-044 2.24208e-044 6.30584e-044 3.92364e-044 3.08286e-044 0 1.82169e-044 1.54143e-044 2.10195e-044 2.45208e-029 7.56701e-044 4.06377e-044 3.92364e-044 3.22299e-044 3.08286e-044 2.66247e-044 2.66247e-044 2.24208e-044 

Note como na segunda execução os números estão muito próximos de zero.

Números desnormalizados são geralmente raros e, portanto, a maioria dos processadores não tenta lidar com eles de maneira eficiente.


Para demonstrar que isso tem tudo a ver com os números desnormalizados, se liberarmos denormals para zero adicionando isso ao início do código:

 _MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON); 

Em seguida, a versão com 0 não é mais 10x mais lenta e, na verdade, se torna mais rápida. (Isso requer que o código seja compilado com o SSE ativado.)

Isso significa que, em vez de usar esses valores quase zero de precisão mais baixa, acabamos de arredondar para zero.

Horários: Core i7 920 @ 3,5 GHz:

 // Don't flush denormals to zero. 0.1f: 0.564067 0 : 26.7669 // Flush denormals to zero. 0.1f: 0.587117 0 : 0.341406 

No final, isso realmente não tem nada a ver com se é um número inteiro ou ponto flutuante. 0 ou 0.1f é convertido / armazenado em um registrador fora de ambos os loops. Então isso não tem efeito no desempenho.

O uso do gcc e a aplicação de um diff no assembly gerado geram apenas essa diferença:

 73c68,69 < movss LCPI1_0(%rip), %xmm1 --- > movabsq $0, %rcx > cvtsi2ssq %rcx, %xmm1 81d76 < subss %xmm1, %xmm0 

O cvtsi2ssq um sendo 10 vezes mais lento de fato.

Aparentemente, a versão float usa um registrador XMM carregado da memory, enquanto a versão int converte um valor int real 0 em float usando a instrução cvtsi2ssq , levando muito tempo. Passar -O3 para o gcc não ajuda. (versão 4.2.1 do gcc)

(O uso de double vez de float não importa, exceto que ele altera o cvtsi2ssq para um cvtsi2sdq .)

Atualizar

Alguns testes extras mostram que não é necessariamente a instrução cvtsi2ssq . Uma vez eliminada (usando um int ai=0;float a=ai; e usando a vez de 0 ), a diferença de velocidade permanece. Então, @Mysticial está certo, os carros alegóricos desnormalizados fazem a diferença. Isso pode ser visto testando valores entre 0 e 0.1f . O ponto de viragem no código acima é aproximadamente de 0.00000000000000000000000000000001 , quando os loops de repente levam 10 vezes mais tempo.

Atualização << 1

Uma pequena visualização deste interessante fenômeno:

  • Coluna 1: um float, dividido por 2 para cada iteração
  • Coluna 2: a representação binária deste float
  • Coluna 3: o tempo necessário para sumr este tempo de flutuação 1e7

Você pode ver claramente o expoente (os últimos 9 bits) mudar para seu valor mais baixo, quando a desnormalização se estabelece. Nesse ponto, a adição simples torna-se 20 vezes mais lenta.

 0.000000000000000000000000000000000100000004670110: 10111100001101110010000011100000 45 ms 0.000000000000000000000000000000000050000002335055: 10111100001101110010000101100000 43 ms 0.000000000000000000000000000000000025000001167528: 10111100001101110010000001100000 43 ms 0.000000000000000000000000000000000012500000583764: 10111100001101110010000110100000 42 ms 0.000000000000000000000000000000000006250000291882: 10111100001101110010000010100000 48 ms 0.000000000000000000000000000000000003125000145941: 10111100001101110010000100100000 43 ms 0.000000000000000000000000000000000001562500072970: 10111100001101110010000000100000 42 ms 0.000000000000000000000000000000000000781250036485: 10111100001101110010000111000000 42 ms 0.000000000000000000000000000000000000390625018243: 10111100001101110010000011000000 42 ms 0.000000000000000000000000000000000000195312509121: 10111100001101110010000101000000 43 ms 0.000000000000000000000000000000000000097656254561: 10111100001101110010000001000000 42 ms 0.000000000000000000000000000000000000048828127280: 10111100001101110010000110000000 44 ms 0.000000000000000000000000000000000000024414063640: 10111100001101110010000010000000 42 ms 0.000000000000000000000000000000000000012207031820: 10111100001101110010000100000000 42 ms 0.000000000000000000000000000000000000006103515209: 01111000011011100100001000000000 789 ms 0.000000000000000000000000000000000000003051757605: 11110000110111001000010000000000 788 ms 0.000000000000000000000000000000000000001525879503: 00010001101110010000100000000000 788 ms 0.000000000000000000000000000000000000000762939751: 00100011011100100001000000000000 795 ms 0.000000000000000000000000000000000000000381469876: 01000110111001000010000000000000 896 ms 0.000000000000000000000000000000000000000190734938: 10001101110010000100000000000000 813 ms 0.000000000000000000000000000000000000000095366768: 00011011100100001000000000000000 798 ms 0.000000000000000000000000000000000000000047683384: 00110111001000010000000000000000 791 ms 0.000000000000000000000000000000000000000023841692: 01101110010000100000000000000000 802 ms 0.000000000000000000000000000000000000000011920846: 11011100100001000000000000000000 809 ms 0.000000000000000000000000000000000000000005961124: 01111001000010000000000000000000 795 ms 0.000000000000000000000000000000000000000002980562: 11110010000100000000000000000000 835 ms 0.000000000000000000000000000000000000000001490982: 00010100001000000000000000000000 864 ms 0.000000000000000000000000000000000000000000745491: 00101000010000000000000000000000 915 ms 0.000000000000000000000000000000000000000000372745: 01010000100000000000000000000000 918 ms 0.000000000000000000000000000000000000000000186373: 10100001000000000000000000000000 881 ms 0.000000000000000000000000000000000000000000092486: 01000010000000000000000000000000 857 ms 0.000000000000000000000000000000000000000000046243: 10000100000000000000000000000000 861 ms 0.000000000000000000000000000000000000000000022421: 00001000000000000000000000000000 855 ms 0.000000000000000000000000000000000000000000011210: 00010000000000000000000000000000 887 ms 0.000000000000000000000000000000000000000000005605: 00100000000000000000000000000000 799 ms 0.000000000000000000000000000000000000000000002803: 01000000000000000000000000000000 828 ms 0.000000000000000000000000000000000000000000001401: 10000000000000000000000000000000 815 ms 0.000000000000000000000000000000000000000000000000: 00000000000000000000000000000000 42 ms 0.000000000000000000000000000000000000000000000000: 00000000000000000000000000000000 42 ms 0.000000000000000000000000000000000000000000000000: 00000000000000000000000000000000 44 ms 

Uma discussão equivalente sobre o ARM pode ser encontrada na pergunta Stack Overflow ponto flutuante desnormalizado em Objective-C? .

É devido ao uso de ponto flutuante desnormalizado. Como se livrar de ambos e a penalidade de desempenho? Tendo vasculhado a Internet em busca de maneiras de matar números denormais, parece que ainda não existe uma “melhor” maneira de fazer isso. Eu encontrei esses três methods que podem funcionar melhor em diferentes ambientes:

  • Pode não funcionar em alguns ambientes do CCG:

     // Requires #include  fesetenv(FE_DFL_DISABLE_SSE_DENORMS_ENV); 
  • Pode não funcionar em alguns ambientes do Visual Studio: 1

     // Requires #include  _mm_setcsr( _mm_getcsr() | (1<<15) | (1<<6) ); // Does both FTZ and DAZ bits. You can also use just hex value 0x8040 to do both. // You might also want to use the underflow mask (1<<11) 
  • Parece funcionar no GCC e no Visual Studio:

     // Requires #include  // Requires #include  _MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON); _MM_SET_DENORMALS_ZERO_MODE(_MM_DENORMALS_ZERO_ON); 
  • O compilador Intel tem opções para desabilitar os denormais por padrão nos modernos processadores da Intel. Mais detalhes aqui

  • Switches do compilador. -ffast-math , -msse ou -mfpmath=sse desabilitarão os denormals e farão algumas outras coisas mais rápidas, mas infelizmente também fazem muitas outras aproximações que podem quebrar seu código. Teste com cuidado! O equivalente de matemática rápida para o compilador do Visual Studio é /fp:fast mas não consegui confirmar se isso também desativa os denormals. 1

No gcc você pode habilitar FTZ e DAZ com isto:

 #include  #define FTZ 1 #define DAZ 1 void enableFtzDaz() { int mxcsr = _mm_getcsr (); if (FTZ) { mxcsr |= (1<<15) | (1<<11); } if (DAZ) { mxcsr |= (1<<6); } _mm_setcsr (mxcsr); } 

use também os switches do gcc: -msse -mfpmath = sse

(créditos correspondentes para Carl Hetherington [1])

[1] http://carlh.net/plugins/denormals.php

O comentário de Dan Neely deveria ser expandido em uma resposta:

Não é a constante zero 0.0f que é desnormalizada ou provoca uma desaceleração, são os valores que se aproximam de zero a cada iteração do loop. À medida que se aproximam cada vez mais de zero, precisam de mais precisão para representar e tornam-se desnormalizados. Estes são os valores y[i] . (Eles se aproximam de zero porque x[i]/z[i] é menor que 1.0 para todos os i .)

A diferença crucial entre as versões lenta e rápida do código é a declaração y[i] = y[i] + 0.1f; . Assim que essa linha é executada a cada iteração do loop, a precisão extra no float é perdida, e a desnormalização necessária para representar essa precisão não é mais necessária. Posteriormente, as operações de ponto flutuante em y[i] permanecem rápidas porque não estão desnormalizadas.

Por que a precisão extra é perdida quando você adiciona 0.1f ? Porque os números de ponto flutuante têm apenas tantos dígitos significativos. Digamos que você tenha armazenamento suficiente para três dígitos significativos, então 0.00001 = 1e-5 e 0.00001 + 0.1 = 0.1 , pelo menos para esse formato flutuante de exemplo, porque ele não tem espaço para armazenar o bit menos significativo em 0.10001 .

Em suma, y[i]=y[i]+0.1f; y[i]=y[i]-0.1f; y[i]=y[i]+0.1f; y[i]=y[i]-0.1f; não é o não-op que você pode pensar que é.

Mystical também disse isso : o conteúdo dos carros alegóricos é importante, não apenas o código de assembly.