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