SIMD fácil mediante envolturas

Publicado: 06/25/2015, Última actualización: 06/25/2015

Por Michael Kopietz, arquitecto de representación de imágenes gráficas de Crytek
Descargar PDF

1. Introducción

Este artículo busca cambiar su forma de pensar acerca de cómo aplicar la programación SIMD al código. Si piensa en los carriles SIMD como si fueran subprocesos de CPU, se le ocurrirán nuevas ideas y podrá aplicar la técnica SIMD con mayor frecuencia en el código.

Intel ha estado produciendo CPU compatibles con SIMD por el doble de tiempo que lleva fabricando CPU multinúcleo; sin embargo, el modelo de subprocesos está mucho más establecido en el desarrollo de software. Uno de los motivos es la abundancia de guías que presentan el trabajo con subprocesos de manera simple, como si se tratara solamente de ejecutar una función de entrada n veces, y dejan de lado todas las posibles complicaciones. Por su parte, las guías de SIMD tienden a concentrarse en alcanzar el 10 % de aceleración final que exige duplicar el tamaño del código. Si estas guías contienen ejemplos, resulta difícil dirigir la atención a toda la información nueva y que al mismo tiempo a uno se le ocurra cómo usarla de forma sencilla y elegante. Por eso, mostrar una manera simple y útil de usar SIMD es el objetivo principal de este artículo.

Primero vamos a explicitar el principio básico del código SIMD: la alineación. Probablemente todo el hardware SIMD exija, o al menos prefiera, cierto grado de alineación natural, y para explicar los aspectos básicos de esto último, se necesitarían unas cuantas páginas [1]. Pero en general, si uno no se está quedando sin memoria, es importante asignarla de manera que no afecte la eficiencia del caché. Para las CPU Intel, ello implica asignar memoria en un límite de 64 bytes, como se muestra en el fragmento de código 1.

inline void* operator new(size_t size)
{
	return _mm_malloc(size, 64);
}

inline void* operator new[](size_t size)
{
	return _mm_malloc(size, 64);
}

inline void operator delete(void *mem)
{
	_mm_free(mem);
}

inline void operator delete[](void *mem)
{
	_mm_free(mem);
}

Fragmento de código 1: Funciones de asignación que respetan límites de 64 bytes para que no se vea perjudicada la eficiencia del caché.

2. La idea básica

La manera de comenzar es sencilla: suponer que cada carril de un registro SIMD se ejecuta como un subproceso. En el caso de Intel® Streaming SIMD Extensions (Intel® SSE), se tienen 4 subprocesos/carriles, mientras que son 8 en Intel® Advanced Ventor Extensions (Intel® AVX) y 16 en los coprocesadores Intel® Xeon-p Phi.

Para contar con una solución inmediata, el primer paso es implementar clases que se comporten en su mayor parte como tipos de datos primitivos. Hay que envolver “int”, “float”, etc. y usar esas envolturas como punto de partida para cada implementación SIMD. Para la versión de Intel SSE, se debe reemplazar el componente flotante __m128, int e int sin signo con __m128i e implementar operadores por medio de funciones intrínsecas de Intel SSE o de Intel AVX, como en el fragmento de código 2.

// VER 128-bit
inline	DRealF	operator+(DRealF R)const{return DRealF(_mm_add_ps(m_V, R.m_V));}
inline	DRealF	operator-(DRealF R)const{return DRealF(_mm_sub_ps(m_V, R.m_V));}
inline	DRealF	operator*(DRealF R)const{return DRealF(_mm_mul_ps(m_V, R.m_V));}
inline	DRealF	operator/(DRealF R)const{return DRealF(_mm_div_ps(m_V, R.m_V));}

// AVX 256-bit
inline	DRealF	operator+(const DRealF& R)const{return DRealF(_mm256_add_ps(m_V, R.m_V));}
inline	DRealF	operator-(const DRealF& R)const{return DRealF(_mm256_sub_ps(m_V, R.m_V));}
inline	DRealF	operator*(const DRealF& R)const{return DRealF(_mm256_mul_ps(m_V, R.m_V));}
inline	DRealF	operator/(const DRealF& R)const{return DRealF(_mm256_div_ps(m_V, R.m_V));}

Fragmento de código 2: Operadores aritméticos sobrecargados para envolturas SIMD

3. Ejemplo de uso

Ahora supongamos que estamos trabajando en dos imágenes HDR en las cuales cada píxel es flotante, y se hace una fusión entre ambas imágenes.

void CrossFade(float* pOut,const float* pInA,const float* pInB,size_t PixelCount,float Factor)

void CrossFade(float* pOut,const float* pInA,const float* pInB,size_t PixelCount,float Factor)
{
	const DRealF BlendA(1.f - Factor);
	const DRealF BlendB(Factor);
	for(size_t i = 0; i < PixelCount; i += THREAD_COUNT)
		*(DRealF*)(pOut + i) = *(DRealF*)(pInA + i) * BlendA + *(DRealF*)(pInB + i) + BlendB;
}

Fragmento de código 3: Función de fusión que puede trabajar tanto con tipos de datos primitivos como con datos SIMD.

El ejecutable generado a partir del fragmento de código 3 se ejecuta nativamente en registros normales y tanto en Intel SSE como Intel AVX. No es realmente el modo convencional en que uno lo escribiría, pero todos los programadores en C++ deberían ser capaces de leerlo y entenderlo. Veamos si es lo que parece. La primera y segunda líneas de la implementación inicializan los factores de fusión de nuestra interpolación lineal; para ello, reproducen el parámetro al ancho que tenga el registro SIMD.

La tercera línea es casi un bucle normal. Lo único fuera de lo común es “THREAD_COUNT”. Vale 1 en el caso de los registros normales, 4 para Intel SSE y 8 para Intel AVX; es la cantidad de carriles contados del registro, que en nuestro caso se parece a la de subprocesos.

La cuarta línea indexa en los arreglos, y ambos píxeles de entrada se cambian de escala en función de los factores de fusión y se los suma. Según la preferencia de escritura, se pueden usar temporales, pero no hay intrínsecas que sea necesario buscar, no hay implementación por plataforma.

4. La hora de la verdad

Ahora llegó el momento de demostrar que funciona. Tomemos una implementación de hash MD5 convencional y usemos todo el poder de cálculo de la CPU para buscar la preimagen. Para ello, reemplazaremos los tipos primitivos con nuestros tipos SIMD. MD5 ejecuta varias “rondas” que aplican diversas operaciones de bit simples en enteros sin signo, como se demostró en el fragmento de código 4.

#define LEFTROTATE(x, c) (((x) << (c)) | ((x) >> (32 - (c))))
#define BLEND(a, b, x) SelectBit(a, b, x)

template<int r>
inline DRealU Step1(DRealU a,DRealU b,DRealU c,DRealU d,DRealU k,DRealU w)
{
	const DRealU f = BLEND(d, c, b);
	return b + LEFTROTATE((a + f + k + w), r); 
}

template<int r>
inline DRealU Step2(DRealU a,DRealU b,DRealU c,DRealU d,DRealU k,DRealU w)
{
	const DRealU f = BLEND(c, b, d);
	return b + LEFTROTATE((a + f + k + w),r);
}

template<int r>
inline DRealU Step3(DRealU a,DRealU b,DRealU c,DRealU d,DRealU k,DRealU w)
{
	DRealU f = b ^ c ^ d;
	return b + LEFTROTATE((a + f + k + w), r);
}

template<int r>
inline DRealU Step4(DRealU a,DRealU b,DRealU c,DRealU d,DRealU k,DRealU w)
{
	DRealU f = c ^ (b | (~d));
	return b + LEFTROTATE((a + f + k + w), r);
}

Fragmento de código 4: Funciones escalón MD5 para envolturas SIMD

Además del nombre de los tipos, hay solo un cambio que podría verse un poco como magia: el “SelectBit”. Si se establece un bit de x, se devuelve el respectivo bit de b; si no, el bit respectivo de a. En otras palabras, una fusión. En el fragmento de código 5 se muestra la función hash MD5 principal.

inline void MD5(const uint8_t* pMSG,DRealU& h0,DRealU& h1,DRealU& h2,DRealU& h3,uint32_t Offset)
{
	const DRealU w0  =	Offset(DRealU(*reinterpret_cast<const uint32_t*>(pMSG + 0 * 4) + Offset));
	const DRealU w1  =	*reinterpret_cast<const uint32_t*>(pMSG + 1 * 4);
	const DRealU w2  =	*reinterpret_cast<const uint32_t*>(pMSG + 2 * 4);
	const DRealU w3  =	*reinterpret_cast<const uint32_t*>(pMSG + 3 * 4);
	const DRealU w4  =	*reinterpret_cast<const uint32_t*>(pMSG + 4 * 4);
	const DRealU w5  =	*reinterpret_cast<const uint32_t*>(pMSG + 5 * 4);
	const DRealU w6  =	*reinterpret_cast<const uint32_t*>(pMSG + 6 * 4);
	const DRealU w7  =	*reinterpret_cast<const uint32_t*>(pMSG + 7 * 4);
	const DRealU w8  =	*reinterpret_cast<const uint32_t*>(pMSG + 8 * 4);
	const DRealU w9  =	*reinterpret_cast<const uint32_t*>(pMSG + 9 * 4);
	const DRealU w10 =	*reinterpret_cast<const uint32_t*>(pMSG + 10 * 4);
	const DRealU w11 =	*reinterpret_cast<const uint32_t*>(pMSG + 11 * 4);
	const DRealU w12 =	*reinterpret_cast<const uint32_t*>(pMSG + 12 * 4);
	const DRealU w13 =	*reinterpret_cast<const uint32_t*>(pMSG + 13 * 4);
	const DRealU w14 =	*reinterpret_cast<const uint32_t*>(pMSG + 14 * 4);
	const DRealU w15 =	*reinterpret_cast<const uint32_t*>(pMSG + 15 * 4);

	DRealU a = h0;
	DRealU b = h1;
	DRealU c = h2;
	DRealU d = h3;

	a = Step1< 7>(a, b, c, d, k0, w0);
	d = Step1<12>(d, a, b, c, k1, w1);
	.
	.
	.
	d = Step4<10>(d, a, b, c, k61, w11);
	c = Step4<15>(c, d, a, b, k62, w2);
	b = Step4<21>(b, c, d, a, k63, w9);

	h0 += a;
	h1 += b;
	h2 += c;
	h3 += d;
}

Fragmento de código 5: La función MD5 principal

La mayoría del código es otra vez como en una función normal de C, excepto que las primeras líneas reproducen nuestros registros SIMD con el parámetro pasado, con el fin de preparar los datos. En este caso, cargamos los registros de SIMD con los datos que queremos “hashear”. Una especialidad es la llamada “Offset”, porque no conviene que todos los carriles SIMD hagan exactamente lo mismo. Esta llamada desplaza el registro en función del índice de carril. Es como agregar un identificador de subproceso. Recomendamos consultar el fragmento de código 6.

Offset(Register)
{
	for(i = 0; i < THREAD_COUNT; i++)
		Register[i] += i;
}

Fragmento de código 6: Offset es una función para trabajar con diferentes anchos de registro.

Eso significa que el primer elemento que debemos llevar a la imagen de la función hash no es [0, 0, 0, 0] para Intel SSE ni [0, 0, 0, 0, 0, 0, 0, 0] para Intel AVX. Son [0, 1, 2, 3] y [0, 1, 2, 3, 4, 5, 6, 7], respectivamente. Esto imita el efecto de ejecutar la función en paralelo por medio de 4 u 8 subprocesos/núcleos, pero en el caso de SIMD, en paralelo a las instrucciones.

En la Tabla 1 podemos ver los resultados de nuestros 10 minutos de exigente trabajo para pasar esta función a SIMD.

Tabla 1: Rendimiento de MD5 con tipos primitivos y SIMD

Tipo Tiempo Aceleración

Entero x86 

379.389s

1.0 vez

SSE4

108.108s

3.5 veces

AVX2

51.490s

7.4 veces

 

5. Más allá de los subprocesos SIMD simples

Los resultados son satisfactorios, sin cambios de escala lineales, ya que hay siempre una parte que no corresponde a subprocesos (es fácil identificarla en el código fuente proporcionado). Pero no apuntamos al último 10 % con el doble de trabajo. Como programadores, preferimos otras soluciones rápidas que maximicen la ganancia. Siempre surgen algunas cuestiones para considerar, como si valdría la pena desenrollar el bucle.

El hashing del MD5 parece depender con frecuencia del resultado de operaciones anteriores, lo cual no se lleva muy bien con los pipelines de CPU, pero podríamos quedar enlazados al registro si desenrollamos. Nuestras envolturas nos pueden ayudar a evaluar esto último con facilidad. Desenrollar es la versión en software del hyper-threading. Emulamos el doble de los subprocesos en ejecución, y para hacer esto repetimos la ejecución de operaciones en el doble de datos que los carriles SIMD disponibles. Por lo tanto, creamos un tipo duplicado similar y desenrollamos en el interior mediante la duplicación de todas las operaciones para nuestros operadores básicos, como en el fragmento de código 7.

struct __m1282
{
	__m128		m_V0;
	__m128		m_V1;
	inline		__m1282(){}
	inline		__m1282(__m128 C0, __m128 C1):m_V0(C0), m_V1(C1){}
};

inline	DRealF	operator+(DRealF R)const
	{return __m1282(_mm_add_ps(m_V.m_V0, R.m_V.m_V0),_mm_add_ps(m_V.m_V1, R.m_V.m_V1));}
inline	DRealF	operator-(DRealF R)const
	{return __m1282(_mm_sub_ps(m_V.m_V0, R.m_V.m_V0),_mm_sub_ps(m_V.m_V1, R.m_V.m_V1));}
inline	DRealF	operator*(DRealF R)const
	{return __m1282(_mm_mul_ps(m_V.m_V0, R.m_V.m_V0),_mm_mul_ps(m_V.m_V1, R.m_V.m_V1));}
inline	DRealF	operator/(DRealF R)const
	{return __m1282(_mm_div_ps(m_V.m_V0, R.m_V.m_V0),_mm_div_ps(m_V.m_V1, R.m_V.m_V1));}

Fragmento de código 7: Estos operadores se reimplementan para trabajar con dos registros SSE al mismo tiempo

Y ya está. Ahora podemos volver a ejecutar los tiempos de la función hash MD5.

Tabla 2: Rendimiento del MD5 con tipos SIMD y desenrollado de bucle

Tipo Tiempo Aceleración

Entero x86

379.389s

1.0 vez

SSE4

108.108s

3.5 veces

SSE4 x2

75.659s

4.8 veces

AVX2

51.490s

7.4 veces

AVX2 x2

36.014s

10.5 veces

 

Los datos de la Tabla 2 muestran que sin dudas vale la pena desenrollar. Logramos mayor velocidad más allá del cambio de escala de conteo de carriles SIMD, probablemente porque la versión entero x86 ya estaba frenando el pipeline con dependencias de operaciones.

6. Subprocesos SIMD más complejos

Hasta ahora nuestros ejemplos fueron simples en el sentido de que el código era el candidato natural para vectorizar a mano. No tenían nada de complejo más allá de un montón de operaciones que exigían muchos cálculos. ¿Pero qué haríamos ante situaciones más complejas, como las bifurcaciones?

La solución es otra vez bastante simple y de uso muy difundido: cálculo especulativo y enmascaramiento. Todo aquel que haya trabajado con sombreadores o lenguajes informáticos ya se habrá encontrado con esto antes. Echemos un vistazo a la rama básica del fragmento de código 8 y reescribámosla a un operador ?:, como en el fragmento de código 9.

int a = 0;
if(i % 2 == 1)
	a = 1;
else
	a = 3;

Fragmento de código 8: Usa if-else para calcular la máscara

int a = (i % 2) ? 1 : 3;

Fragmento de código 9: Usa el operador ternario ?:  para calcular la máscara.

También podemos usar el operador selector de bits del fragmento de código 4 y lograr lo mismo solo con operaciones de bits en el fragmento de código 10.

int Mask = (i % 2) ? ~0 : 0;
int a = SelectBit(3, 1, Mask);

Fragmento de código 10: El uso de SelectBit prepara para los registros SIMD como datos

Eso parecería ser inútil si todavía tenemos un operador ?: para crear la máscara, y la comparación no da un resultado de verdadero o falso, sino bits establecidos o eliminados. Pero no hay ningún problema, porque la cantidad total de bits establecidos o eliminados es lo que realmente devuelve la instrucción de comparación de Intel SSE y Intel AVX.

Por supuesto que en lugar de asignar solo 3 o 1, se puede llamar a funciones y seleccionar la devolución de resultado que uno desee. De esa manera podría mejorarse el rendimiento incluso en código no vectorizado, porque se evitan las bifurcaciones y la CPU nunca sufre por predicción errónea de bifurcación, aunque cuanto más complejas sean las funciones que uno llame, mayor posibilidad habrá de predicciones erróneas. Incluso en el código vectorizado, evitaremos ejecutar bifurcaciones largas innecesarias. La manera de hacerlo es revisar los casos especiales en los cuales todos los elementos de nuestro registro SIMD tienen el mismo resultado de comparación, como se muestra en el fragmento de código 11.

int Mask = (i % 2) ? ~0 : 0;
int a = 0;
if(All(Mask))
	a = Function1();
else
if(None(Mask))
	a = Function3();
else
	a = BitSelect(Function3(), Function1(), Mask);

Fragmento de código 11: Muestra una selección sin bifurcaciones y optimizada, entre dos funciones

Así se detectan los casos especiales en los cuales todos los elementos son “verdadero” o todos son “falso”. Esos casos se ejecutan en SIMD de la misma manera que en x86. El flujo de ejecución divergiría nada más que en el último “else”. Por lo tanto, tenemos que usar selección de bits.

Si Function1 o Function3 modifican algún dato, habrá que pasar la máscara por la llamada y seleccionar las modificaciones por bits de manera explícita, tal como lo hemos hecho en este apartado. Para ser una solución inmediata, lleva bastante trabajo, pero el código que se obtiene pueden leerlo la mayoría de los programadores.

7. Ejemplo de código

Volvamos a tomar código fuente y echar en él nuestros tipos SIMD. Un caso muy interesante es el uso de trazado de rayos para campos de distancia. Usaremos la escena de la demo de Iñigo Quilez [2], que ha tenido la gentileza de darnos su permiso. La imagen se muestra en la Figura 1.

Figura 1: Escena de prueba de la demo de raycasting de Iñigo Quilez.

El “subprocesamiento SIMD” se coloca donde uno agregaría el subprocesamiento. Cada subproceso se encarga de un píxel, y atraviesa el escenario hasta chocar contra algo. Después, se aplica un poco de sombreado, se convierte el píxel a RGBA y se lo escribe al búfer de tramas.

El acto de atravesar la escena se hace de manera iterativa. Cada rayo tiene una cantidad impredecible de pasos hasta que se reconoce un choque. Por ejemplo, si hubiera una pared en primer plano, se alcanzaría después de pocos pasos, mientras que algunos rayos se desplazan la distancia máxima de trazado sin chocar contra nada. El bucle principal del fragmento de código 12 se encarga de ambos casos. Usa el método de selección de bits que tratamos en la sección anterior.

DRealU LoopMask(RTrue);
for(; a < 128; a++)

{
      DRealF Dist             =     SceneDist(O.x, O.y, O.z, C);
      DRealU DistU            =     *reinterpret_cast<DRealU*>(&Dist) & DMask(LoopMask);
      Dist                    =     *reinterpret_cast<DRealF*>(&DistU);
      TotalDist               =     TotalDist + Dist;
      O                       +=    D * Dist;
      LoopMask                =     LoopMask && Dist > MinDist && TotalDist < MaxDist;
      if(DNone(LoopMask))
            break;
}

Fragmento de código 12: Raycasting con tipos SIMD

La variable LoopMask identifica con ~0 o 0 que un rayo está activo, en cuyo caso ya terminamos con ese rayo. Al final del bucle, nos fijamos si ya no hay rayos activos, y si no los hay, salimos del bucle.

En la línea de arriba, evaluamos nuestras condiciones para los rayos y determinamos si estamos lo suficientemente cerca de un objeto para considerarlo un choque o si el rayo ya ha sobrepasado la distancia máxima que queremos trazar. Lo unimos lógicamente al resultado anterior con AND, dado que el rayo podría haber sido ya dejado de lado en una de las iteraciones anteriores.

“SceneDist” es la función de evaluación para el trazado: se ejecuta para todos los carriles SIMD y se trata de una función muy ponderada que devuelve la distancia actual al objeto más cercano. La línea siguiente establece en 0 la distancia a los elementos en el caso de los rayos que ya no están activos y traslada esta cantidad para la iteración siguiente.

La “SceneDist” original tenía algunas optimizaciones para ensamblador y un manejo de materiales que no necesitamos en nuestra prueba. Esta función está reducida al mínimo que necesitamos para tener un ejemplo complejo. Todavía contiene algunos “if” que se manejan de la misma manera que antes. En general, “SceneDist” es bastante grande y compleja. Llevaría mucho tiempo reescribirla a mano para cada plataforma SIMD una y otra vez. Habría que convertirla toda de un plumazo, y algunos errores al escribirla podrían hacer que los resultados fueran incorrectos. Además, aunque funcionara, tendríamos solo unas pocas funciones que realmente entenderíamos, además de que exige mucha mayor intervención. Hacerlo a mano sería el último recurso. Comparado con eso, nuestros cambios son relativamente pequeños. Es fácil de modificar y es posible ampliar el aspecto visual sin necesidad de preocuparse por volver a optimizarla y de ser el único que entiende el código; es igual que si agregáramos subprocesos reales.

El trabajo que hicimos fue para ver resultados, así que analicemos los tiempos de la Tabla 3.

Tabla 3: Rendimiento de trazado de rayos con tipo primitivos y SIMD, incluidos los de desenrollado de bucles.

Tipo FPS Aceleración

x86

0.992FPS

1.0 vez

SSE4

3.744FPS

3.8 veces

SSE4 x2

3.282FPS

3.3 veces

AVX2

6.960FPS

7.0 veces

AVX2 x2

5.947FPS

6.0 veces

 

Se puede ver con claridad que la aceleración no se modifica linealmente con la cantidad de elementos, lo cual se debe más que nada a la divergencia. Algunos rayos podrían necesitar 10 veces más iteraciones que otros.

8. ¿Por qué no dejamos que lo haga el compilador?

Los compiladores actuales son capaces de vectorizar hasta cierto grado, pero la mayor prioridad para el código generado es que los resultados sean correctos, ya que nadie usaría binarios 100 veces más rápidos si los resultados que dieran fueran erróneos, por más que solo fuera el 1 % de las veces. Algunas de nuestras suposiciones, como que los datos están alineados para SIMD y asignamos suficiente relleno como para no sobrescribir asignaciones consecutivas, escapan a las posibilidades del compilador. Uno puede recibir anotaciones del compilador Intel acerca de todas las oportunidades que tuvo de hacer omisiones por suposiciones que no podía garantizar, y a partir de ello intentar reorganizar el código y hacer promesas al compilador para que genere la versión vectorizada. Pero habría que hacer este trabajo cada vez que se modifique el código, y en casos más complejos, como cuando hay bifurcación, uno no puede más que adivinar si el resultado va a ser código serializado o selección de bits sin bifurcación.

Además, el compilador no tiene idea de lo que uno quiere crear. Uno sabe si los subprocesos van a divergir o ser coherentes, e implementa una solución bifurcada o que seleccione bits. También ve el punto de ataque, el bucle que más sentido tendría cambiar a SIMD, mientras que al compilador no le queda sino adivinar si va a iterar diez veces o un millón.

Al confiar la vectorización al compilador, se gana por una parte y se pierde por otra. Es bueno contar con esta opción, tal como la de colocar subprocesos a mano.

9. ¿Subprocesamiento real?

Sí, el subprocesamiento real es útil y los subprocesos SIMD no son un reemplazo; ambos son ortogonales. Los subprocesos SIMD todavía no son tan simples de ejecutar como los reales, pero causan menos problemas de sincronización y pocas veces producen errores. La gran ventaja es que todos los núcleos que vende Intel pueden ejecutar las versiones de subprocesos SIMD con todos los “subprocesos”. Una CPU de dos núcleos funcionará 4 u 8 veces más rápido, igual que el Haswell-EP de 15 núcleos y cuatro zócalos. En las tablas 4 a 7 se resumen algunos resultados de nuestros bancos de pruebas en combinación con subprocesamiento.

Tabla 4: Rendimiento de MD5 en Intel® Core™ i7 4770K con SIMD y con subprocesamiento

Subprocesos Tipo Tiempo Aceleración

1T

Entero x86

311.704s

1.00 vez

8T

Entero x86

47.032s

6.63 veces

1T

SSE4

90.601s

3.44 veces

8T

SSE4

14.965s

20.83 veces

1T

SSE4 x2

62.225s

5.01 veces

8T

SSE4 x2

12.203s

25.54 veces

1T

AVX2

42.071s

7.41 veces

8T

AVX2

6.474s

48.15 veces

1T

AVX2 x2

29.612s

10.53 veces

8T

AVX2 x2

5.616s

55.50 veces

 

Tabla 5: Rendimiento de trazado de rayos en Intel® Core™ i7 4770K con SIMD y con subprocesamiento

Subprocesos Tipo FPS Aceleración

1T

Entero x86

1.202FPS

1.00 vez

8T

Entero x86

6.019FPS

5.01 veces

1T

SSE4

4.674FPS

3.89 veces

8T

SSE4

23.298FPS

19.38 veces

1T

SSE4 x2

4.053FPS

3.37 veces

8T

SSE4 x2

20.537FPS

17.09 veces

1T

AVX2

8.646FPS

4.70 veces

8T

AVX2

42.444FPS

35.31 veces

1T

AVX2 x2

7.291FPS

6.07 veces

8T

AVX2 x2

36.776FPS

30.60 veces

Tabla 6: Rendimiento de MD5 en Intel® Core™ i7 5960X con SIMD y con subprocesamiento

Subprocesos Tipo Tiempo Aceleración

1T

Entero x86

379.389s

1.00 vez

16T

Entero x86

28.499s

13.34 veces

1T

SSE4

108.108s

3.51 veces

16T

SSE4

9.194s

41.26 veces

1T

SSE4 x2

75.694s

5.01 veces

16T

SSE4 x2

7.381s

51.40 veces

1T

AVX2

51.490s

3.37 veces

16T

AVX2

3.965s

95.68 veces

1T

AVX2 x2

36.015s

10.53 veces

16T

AVX2 x2

3.387s

112.01 veces

 

Tabla 7: Rendimiento de trazado de rayos en Intel® Core™ i7 5960X con SIMD y con subprocesamiento

Subprocesos Tipo FPS Aceleración

1T

Entero x86

0.992FPS

1.00 vez

16T

Entero x86

6.813FPS

6.87 veces

1T

SSE4

3.744FPS

3.774 veces

16T

SSE4

37.927FPS

38.23 veces

1T

SSE4 x2

3.282FPS

3.31 veces

16T

SSE4 x2

33.770FPS

34.04 veces

1T

AVX2

6.960FPS

7.02 veces

16T

AVX2

70.545FPS

71.11 veces

1T

AVX2 x2

5.947FPS

6.00 veces

16T

AVX2 x2

59.252FPS

59.76 veces

 

1 El software y las cargas de trabajo usados en la pruebas de rendimiento puede que hayan sido optimizados para rendimiento en microprocesadores Intel solamente. Las pruebas de rendimiento, tales como SYSmark* y MobileMark*, se miden con sistemas informáticos, componentes, software, operaciones y funciones específicos. Todo cambio en cualquiera de esos factores puede hacer que varíen los resultados. Debe consultar más información y otras pruebas de rendimiento que lo ayuden a evaluar íntegramente las compras que contemple hacer, incluido el rendimiento del producto al combinarlo con otros. Encontrará más información en http://www.intel.com/performance.

Como puede verse, los resultados varían en función de la CPU; los resultados de subprocesos SIMD cambian de manera similar. Llama la atención que se logran factores de aceleración de más de 30 cuando se combinan ambas ideas. Tiene sentido optar por la aceleración por ocho en CPU de dos núcleos, pero también lo tiene ir por ocho veces más en hardware más sofisticado.

¡Vamos! ¡Hay que animarse y sumar SIMD al código!

Acerca del autor

Michael Kopietz es arquitecto de representación gráfica del departamento de investigación de Crytek. Lidera un equipo de ingenieros que se encargan de la representación gráfica de CryEngine(R) y también orienta a estudiantes que están preparando sus tesis. Trabajó, entre otras cosas, en arquitectura de representación gráfica multiplataforma, software de representación gráfica y servidores de alta sensibilidad, siempre con la idea de lograr alto rendimiento y trabajar con código reutilizable. Antes, participó en el desarrollo de juegos de batallas navales y simulación de fútbol. Como sus inicios fueron en la programación en ensamblador de las primeras consolas hogareñas, para él cada ciclo cuenta.

Licencia del código

Todo el código del artículo es © 2014 Crytek GmbH, y se publica bajo la licencia https://software.intel.com/en-us/articles/intel-sample-source-code-license-agreement. Todos los derechos reservados.

Enlaces de consulta

[1] Manejo de memoria para optimizar el rendimiento en el coprocesador Intel® Xeon Phi™: alineación y precarga https://software.intel.com/en-us/articles/memory-management-for-optimal-performance-on-intel-xeon-phi-coprocessor-alignment-and

[2] Representación de escenarios con dos triángulos, por Iñigo Quilez http://www.iquilezles.org/www/material/nvscene2008/nvscene2008.htm

 

Adjunto Tamaño
intelsimd033-560562.pdf 1.5 MB

Información sobre productos y desempeño

1

Los compiladores Intel pueden o no optimizar al mismo nivel para los microprocesadores que no son Intel en optimizaciones que no son exclusivas de los microprocesadores Intel. Estas optimizaciones incluyen los conjuntos de instrucciones SSE2, SSE3 y SSSE3, y otras optimizaciones. Intel no garantiza la disponibilidad, funcionalidad o eficacia de ninguna optimización en microprocesadores que no sean fabricados por Intel. Las optimizaciones dependientes del microprocesador en este producto fueron diseñadas para usarse con microprocesadores Intel. Ciertas optimizaciones no específicas de la microarquitectura Intel se reservan para los microprocesadores Intel. Consulte las guías de referencia y para el usuario para obtener más información acerca de los conjuntos de instrucciones específicos cubiertos por este aviso.

Revisión del aviso n.° 20110804