¿Por qué SSE scalar sqrt (x) es más lento que rsqrt (x) * x?

106

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).

Crashworks
fuente
13
x / sqrt (x) = sqrt (x). O, dicho de otra manera: x ^ 1 * x ^ (- 1/2) = x ^ (1 - 1/2) = x ^ (1/2) = sqrt (x)
Crashworks
6
por supuesto 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.
Crashworks
4
La importancia de LHS depende de la generación particular y el paso de un x86 dado: mi experiencia es que en cualquier cosa hasta i7, mover datos entre conjuntos de registros (por ejemplo, FPU a SSE a 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
Crashworks
2
Para PowerPC, sí: IBM tiene un simulador de CPU que puede predecir LHS y muchas otras burbujas de canalización mediante análisis estático. Algunas PPC también tienen un contador de hardware para LHS que puede sondear. Es más difícil para el x86; Las buenas herramientas de creación de perfiles son más escasas (VTune está algo roto en estos días) y las tuberías reordenadas son menos deterministas. Puede intentar medirlo empíricamente midiendo instrucciones por ciclo, lo que se puede hacer con precisión con los contadores de rendimiento del hardware. Los registros de "instrucciones retiradas" y "ciclos totales" se pueden leer con, por ejemplo, PAPI o PerfSuite ( bit.ly/an6cMt ).
Crashworks
2
También puede simplemente escribir algunas permutaciones en una función y cronometrarlas para ver si alguna sufre particularmente de bloqueos. Intel no publica muchos detalles sobre la forma en que funcionan sus tuberías (que LHS es una especie de secreto sucio), por lo que mucho de lo que aprendí fue al observar un escenario que causa un estancamiento en otros arcos (por ejemplo, PPC ), y luego construir un experimento controlado para ver si el x86 también lo tiene.
Crashworks

Respuestas:

216

sqrtssda un resultado correctamente redondeado. rsqrtssda una aproximación al recíproco, con una precisión de aproximadamente 11 bits.

sqrtssestá generando un resultado mucho más preciso, para cuando se requiere precisión. rsqrtssexiste para los casos en que una aproximación es suficiente, pero se requiere velocidad. Si lee la documentación de Intel, también encontrará una secuencia de instrucciones (aproximación recíproca de raíz cuadrada seguida de un solo paso de Newton-Raphson) que brinda una precisión casi total (~ 23 bits de precisión, si mal no recuerdo), y todavía es algo más rápido que sqrtss.

editar: Si la velocidad es crítica, y realmente está llamando a esto en un bucle para muchos valores, debería usar las versiones vectorizadas de estas instrucciones, rsqrtpso sqrtpsambas procesan cuatro flotantes por instrucción.

Stephen Canon
fuente
3
El paso n / r le da 22 bits de precisión (lo duplica); 23 bits sería exactamente la máxima precisión.
Jasper Bekkers
7
@Jasper Bekkers: No, no lo haría. Primero, float tiene 24 bits de precisión. En segundo lugar, sqrtssse redondea correctamente , lo que requiere ~ 50 bits antes del redondeo, y no se puede lograr utilizando una iteración N / R simple con precisión simple.
Stephen Canon
1
Esta es definitivamente la razón. Para ampliar este resultado: el proyecto Embree de Intel ( software.intel.com/en-us/articles/… ), utiliza la vectorización para sus matemáticas. Puede descargar la fuente en ese enlace y ver cómo hacen sus vectores 3/4 D. Su normalización vectorial usa rsqrt seguido de una iteración de newton-raphson, que luego es muy precisa y aún más rápida que 1 / ssqrt.
Brandon Pelfrey
7
Una pequeña advertencia: x rsqrt (x) da como resultado NaN si x es cero o infinito. 0 * rsqrt (0) = 0 * INF = NaN. INF rsqrt (INF) = INF * 0 = NaN. Por esta razón, CUDA en las GPU NVIDIA calcula raíces cuadradas de precisión simple aproximadas como recip (rsqrt (x)), y el hardware proporciona una aproximación rápida a la raíz cuadrada recíproca y recíproca. Obviamente, las comprobaciones explícitas que manejan los dos casos especiales también son posibles (pero serían más lentas en la GPU).
njuffa
@BrandonPelfrey ¿En qué archivo encontró el escalón de Newton Rhapson?
fredoverflow
7

Esto también es válido para la división. MULSS (a, RCPSS (b)) es mucho más rápido que DIVSS (a, b). De hecho, es aún más rápido incluso cuando aumenta su precisión con una iteración Newton-Raphson.

Tanto Intel como AMD recomiendan esta técnica en sus manuales de optimización. En aplicaciones que no requieren el cumplimiento de IEEE-754, la única razón para usar div / sqrt es la legibilidad del código.

Escupió
fuente
1
Broadwell y versiones posteriores tienen un mejor rendimiento de división de FP, por lo que los compiladores como clang eligen no usar recíproco + Newton para escalar en CPU recientes, porque generalmente no es más rápido. En la mayoría de los bucles, divno es la única operación, por lo que el rendimiento total de uop suele ser el cuello de botella incluso cuando hay un divpso divss. Consulte División de punto flotante frente a multiplicación de punto flotante , donde mi respuesta tiene una sección sobre por qué rcppsya no es una ganancia de rendimiento. (O una latencia ganadora) y números en dividir rendimiento / latencia.
Peter Cordes
Si sus requisitos de precisión son tan bajos que puede omitir una iteración de Newton, entonces sí a * rcpss(b)puede ser más rápido, ¡pero aún así es más uops que a/b!
Peter Cordes
5

En lugar de proporcionar una respuesta, eso en realidad podría ser incorrecto (tampoco voy a verificar ni discutir sobre la caché y otras cosas, digamos que son idénticas), intentaré señalarle la fuente que puede responder a su pregunta.
La diferencia podría estar en cómo se calculan sqrt y rsqrt. Puede leer más aquí http://www.intel.com/products/processor/manuals/ . Sugeriría comenzar leyendo sobre las funciones del procesador que está usando, hay algo de información, especialmente sobre rsqrt (la CPU está usando una tabla de búsqueda interna con una gran aproximación, lo que hace que sea mucho más simple obtener el resultado). Puede parecer que rsqrt es mucho más rápido que sqrt, que 1 operación mul adicional (que no es demasiado costosa) podría no cambiar la situación aquí.

Editar: Algunos hechos que valdría la pena mencionar:
1. Una vez estaba haciendo algunas microoptimizaciones para mi biblioteca de gráficos y usé rsqrt para calcular la longitud de los vectores. (en lugar de sqrt, he multiplicado mi suma de cuadrados por rsqrt, que es exactamente lo que has hecho en tus pruebas), y funcionó mejor.
2. Calcular rsqrt usando una tabla de búsqueda simple podría ser más fácil, como para rsqrt, cuando x va al infinito, 1 / sqrt (x) va a 0, por lo que para las x pequeñas los valores de la función no cambian (mucho), mientras que para sqrt: va al infinito, por lo que es un caso simple;).

Además, una aclaración: no estoy seguro de dónde lo encontré en los libros que he vinculado, pero estoy bastante seguro de haber leído que rsqrt está usando una tabla de búsqueda, y solo debe usarse cuando el resultado no es necesario que sea exacto, aunque yo también podría estar equivocado, como lo fue hace algún tiempo :)

Marcin Deptuła
fuente
4

Newton-Raphson converge al cero de f(x)usar incrementos iguales a -f/f' donde f'está la derivada.

Para x=sqrt(y), se puede tratar de resolver f(x) = 0por xel uso f(x) = x^2 - y;

Entonces el incremento es: dx = -f/f' = 1/2 (x - y/x) = 1/2 (x^2 - y) / x que tiene una división lenta.

Puedes probar otras funciones (como f(x) = 1/y - 1/x^2) pero serán igualmente complicadas.

Veamos 1/sqrt(y)ahora. Puedes intentarlo f(x) = x^2 - 1/y, pero será igualmente complicado: dx = 2xy / (y*x^2 - 1)por ejemplo. Una opción alternativa no obvia para f(x)es:f(x) = y - 1/x^2

Luego: dx = -f/f' = (y - 1/x^2) / (2/x^3) = 1/2 * x * (1 - y * x^2)

¡Ah! No es una expresión trivial, pero solo tiene multiplicaciones, no divide. => ¡Más rápido!

Y: el paso de actualización completo new_x = x + dxluego dice:

x *= 3/2 - y/2 * x * x que también es fácil.

skal
fuente
2

Hay varias otras respuestas a esto ya desde hace unos años. Esto es lo que acertó el consenso:

  • Las instrucciones rsqrt * calculan una aproximación a la raíz cuadrada recíproca, buena para aproximadamente 11-12 bits.
  • Se implementa con una tabla de búsqueda (es decir, una ROM) indexada por la mantisa. (De hecho, es una tabla de búsqueda comprimida, similar a las tablas matemáticas de antaño, que utiliza ajustes en los bits de orden inferior para ahorrar en transistores).
  • La razón por la que está disponible es que es la estimación inicial utilizada por la FPU para el algoritmo de raíz cuadrada "real".
  • También hay una instrucción recíproca aproximada, rcp. Ambas instrucciones son una pista de cómo la FPU implementa la raíz cuadrada y la división.

Esto es lo que se equivocó en el consenso:

  • Las FPU de la era SSE no utilizan Newton-Raphson para calcular raíces cuadradas. Es un gran método en software, pero sería un error implementarlo de esa manera en hardware.

El algoritmo NR para calcular la raíz cuadrada recíproca tiene este paso de actualización, como otros han señalado:

x' = 0.5 * x * (3 - n*x*x);

Eso es un montón de multiplicaciones dependientes de datos y una resta.

Lo que sigue es el algoritmo que utilizan realmente las FPU modernas.

Dado b[0] = n, suponga que podemos encontrar una serie de números Y[i]tal que se b[n] = b[0] * Y[0]^2 * Y[1]^2 * ... * Y[n]^2acerque a 1. Luego considere:

x[n] = b[0] * Y[0] * Y[1] * ... * Y[n]
y[n] = Y[0] * Y[1] * ... * Y[n]

Es evidente que x[n]se acerca sqrt(n)y y[n]se acerca 1/sqrt(n).

Podemos usar el paso de actualización de Newton-Raphson para la raíz cuadrada recíproca para obtener un buen Y[i]:

b[i] = b[i-1] * Y[i-1]^2
Y[i] = 0.5 * (3 - b[i])

Luego:

x[0] = n Y[0]
x[i] = x[i-1] * Y[i]

y:

y[0] = Y[0]
y[i] = y[i-1] * Y[i]

La siguiente observación clave es esa b[i] = x[i-1] * y[i-1]. Entonces:

Y[i] = 0.5 * (3 - x[i-1] * y[i-1])
     = 1 + 0.5 * (1 - x[i-1] * y[i-1])

Luego:

x[i] = x[i-1] * (1 + 0.5 * (1 - x[i-1] * y[i-1]))
     = x[i-1] + x[i-1] * 0.5 * (1 - x[i-1] * y[i-1]))
y[i] = y[i-1] * (1 + 0.5 * (1 - x[i-1] * y[i-1]))
     = y[i-1] + y[i-1] * 0.5 * (1 - x[i-1] * y[i-1]))

Es decir, dados xey iniciales, podemos usar el siguiente paso de actualización:

r = 0.5 * (1 - x * y)
x' = x + x * r
y' = y + y * r

O, incluso más elegante, podemos configurar h = 0.5 * y. Esta es la inicialización:

Y = approx_rsqrt(n)
x = Y * n
h = Y * 0.5

Y este es el paso de actualización:

r = 0.5 - x * h
x' = x + x * r
h' = h + h * r

Este es el algoritmo de Goldschmidt, y tiene una gran ventaja si lo está implementando en hardware: el "bucle interno" es tres adiciones múltiples y nada más, y dos de ellos son independientes y se pueden canalizar.

En 1999, las FPU ya necesitaban un circuito de suma / resta en canalización y un circuito de multiplicación en canalización; de lo contrario, SSE no sería muy "fluido". En 1999, solo se necesitaba uno de cada circuito para implementar este bucle interno de una manera completamente canalizada sin desperdiciar mucho hardware solo en la raíz cuadrada.

Hoy, por supuesto, hemos fusionado la adición múltiple expuesta al programador. Una vez más, el bucle interno son tres FMA canalizados, que son (nuevamente) generalmente útiles incluso si no está calculando raíces cuadradas.

Seudónimo
fuente
1
Relacionado: ¿Cómo funciona sqrt () de GCC después de compilado? ¿Qué método de raíz se utiliza? Newton-Raphson? tiene algunos enlaces a diseños de unidades de ejecución div / sqrt de hardware. Rsqrt vectorizado rápido y recíproco con SSE / AVX dependiendo de la precisión : una iteración de Newton en el software, con o sin FMA, para usar con _mm256_rsqrt_ps, con análisis de perf de Haswell. Por lo general, solo es una buena idea si no tiene otro trabajo en el bucle y se produciría un cuello de botella en el rendimiento del divisor. HW sqrt es uop único, por lo que está bien mezclado con otro trabajo.
Peter Cordes
-2

Es más rápido porque estas instrucciones ignoran los modos de redondeo y no manejan excepciones de punto flotante o números desnormalizados. Por estas razones, es mucho más fácil canalizar, especular y ejecutar otras instrucciones fp fuera de servicio.

Witek
fuente
Obviamente mal. FMA depende del modo de redondeo actual, pero tiene un rendimiento de dos por reloj en Haswell y versiones posteriores. Con dos unidades FMA completamente canalizadas, Haswell puede tener hasta 10 FMA en vuelo a la vez. La respuesta correcta es rsqrtla precisión mucho menor, lo que significa mucho menos trabajo por hacer (¿o nada?) Después de una búsqueda en la tabla para obtener una suposición inicial.
Peter Cordes