He estado perfilando algunas de nuestras matemáticas básicas en un Intel Core Duo, y mientras observaba varios enfoques de raíz cuadrada, noté algo extraño: al usar las operaciones escalares SSE, es más rápido tomar una raíz cuadrada recíproca y multiplicarla para obtener el sqrt, que usar el código de operación sqrt nativo.
Lo estoy probando con un bucle algo como:
inline float TestSqrtFunction( float in );
void TestFunc()
{
#define ARRAYSIZE 4096
#define NUMITERS 16386
float flIn[ ARRAYSIZE ]; // filled with random numbers ( 0 .. 2^22 )
float flOut [ ARRAYSIZE ]; // filled with 0 to force fetch into L1 cache
cyclecounter.Start();
for ( int i = 0 ; i < NUMITERS ; ++i )
for ( int j = 0 ; j < ARRAYSIZE ; ++j )
{
flOut[j] = TestSqrtFunction( flIn[j] );
// unrolling this loop makes no difference -- I tested it.
}
cyclecounter.Stop();
printf( "%d loops over %d floats took %.3f milliseconds",
NUMITERS, ARRAYSIZE, cyclecounter.Milliseconds() );
}
Probé esto con algunos cuerpos diferentes para TestSqrtFunction, y tengo algunos tiempos que realmente me están rascando la cabeza. Lo peor de todo fue usar la función sqrt () nativa y dejar que el compilador "inteligente" se "optimice". A 24ns / float, usar el x87 FPU esto fue patéticamente malo:
inline float TestSqrtFunction( float in )
{ return sqrt(in); }
Lo siguiente que intenté fue usar un intrínseco para forzar al compilador a usar el código de operación sqrt escalar de SSE:
inline void SSESqrt( float * restrict pOut, float * restrict pIn )
{
_mm_store_ss( pOut, _mm_sqrt_ss( _mm_load_ss( pIn ) ) );
// compiles to movss, sqrtss, movss
}
Esto fue mejor, a 11,9 ns / flotación. También probé la extraña técnica de aproximación Newton-Raphson de Carmack , que funcionó incluso mejor que el hardware, a 4.3ns / float, aunque con un error de 1 en 2 10 (que es demasiado para mis propósitos).
La sorpresa fue cuando probé la operación SSE para la raíz cuadrada recíproca , y luego usé una multiplicación para obtener la raíz cuadrada (x * 1 / √x = √x). Aunque esto elimina dos operaciones dependientes, que era la solución más rápida, con mucho, en 1.24ns / flotador y una precisión de 2 -14 :
inline void SSESqrt_Recip_Times_X( float * restrict pOut, float * restrict pIn )
{
__m128 in = _mm_load_ss( pIn );
_mm_store_ss( pOut, _mm_mul_ss( in, _mm_rsqrt_ss( in ) ) );
// compiles to movss, movaps, rsqrtss, mulss, movss
}
Mi pregunta es básicamente ¿qué da ? ¿Por qué el código de operación de raíz cuadrada integrado en el hardware de SSE es más lento que sintetizarlo a partir de otras dos operaciones matemáticas?
Estoy seguro de que este es realmente el costo de la operación en sí, porque he verificado:
- Todos los datos caben en la caché y los accesos son secuenciales
- las funciones están en línea
- desenrollar el bucle no hace ninguna diferencia
- Los indicadores del compilador están configurados para la optimización completa (y el ensamblaje es bueno, lo verifiqué)
( editar : stephentyrone señala correctamente que las operaciones en cadenas largas de números deben usar las operaciones empaquetadas SIMD de vectorización, como rsqrtps
, pero la estructura de datos de la matriz aquí es solo para fines de prueba: lo que realmente estoy tratando de medir es el rendimiento escalar para usar en el código que no se puede vectorizar).
inline float SSESqrt( float restrict fIn ) { float fOut; _mm_store_ss( &fOut, _mm_sqrt_ss( _mm_load_ss( &fIn ) ) ); return fOut; }
. Pero esta es una mala idea porque puede inducir fácilmente un bloqueo de carga-golpe-tienda si la CPU escribe los flotantes en la pila y luego los vuelve a leer inmediatamente, haciendo malabarismos desde el registro vectorial a un registro flotante para el valor de retorno en particular es una mala noticia. Además, los códigos de operación subyacentes de la máquina que representan los intrínsecos SSE toman operandos de dirección de todos modos.
eax
) es muy malo, mientras que un viaje de ida y vuelta entre xmm0 y la pila y la espalda no lo es, debido al reenvío de tienda de Intel. Puedes cronometrarlo tú mismo para verlo con seguridad. Generalmente, la forma más fácil de ver el LHS potencial es mirar el ensamblaje emitido y ver dónde se combinan los datos entre los conjuntos de registros; su compilador puede hacer algo inteligente, o puede que no. En cuanto a la normalización de vectores, escribí mis resultados aquí: bit.ly/9W5zoU