Mostrando entradas con la etiqueta optimización. Mostrar todas las entradas
Mostrando entradas con la etiqueta optimización. Mostrar todas las entradas

lunes, 24 de junio de 2013

Mejorar la calidad de código como manera de optimizar el rendimiento

Artículo perteneciente a la sección de calidad del código

En muchas ocasiones oiréis (o incluso diréis): "he tenido que hacer esta guarrada para poder optimizar este caso concreto; se que no es demasiado bonito, pero era la única manera de optimizarlo". Tenemos metido en la cabeza, desde que empezamos a programar, que los códigos o son mantenibles o son óptimos, cuando la verdad es que normalmente cuando "optimizamos" siempre enguarramos el código mientras que cuando mejoramos la mantenibilidad del código casi siempre mejoramos el rendimiento de nuestro software, casi casi sin quererlo.

Es cierto que esta mejora del rendimiento es genérica y se esparce de igual manera por todo nuestro código y que tal vez no afecte tanto como nos gustaría a nuestra parte más crítica del programa. En este caso, cuando ya hemos hecho todo lo posible para mejorar la calidad del código, es cuando es lícito que apliquemos optimizaciones (como el uso de asm) a las zonas críticas, pero no antes.

El motivo por el cual la mejora de la calidad de código mejora nuestro rendimiento es sencillo. Mejorar la calidad de nuestros programas tiene dos componentes muy marcados: reducir duplicaciones de código(refactorizar) y disminuir la complejidad ciclomática.

Disminuyendo la complejidad ciclomática disminuimos el número de condicionales de nuestras funciones, es decir, reducimos cases dentro de los switchs y reducimos if's. Estas funciones condicionales son de las que más impactan en el rendimiento de nuestro código ya que son las que rompen el pipeline del procesador (lo explicaremos en otro artículo con más calma)  y hacen que el rendimiento de este baje en picado, por lo tanto, reduciendo condicionales aumentamos de forma directa la velocidad de proceso de nuestra aplicación.

Disminuyendo las duplicaciones de código por un lado permitimos que nuestras funciones quepan mejor en la cache de la CPU (nuevamente, lo explicaremos en otro artículo) haciendo que la velocidad de ejecución pueda aumentar drásticamente y por otro lado nos aseguramos que las optimizaciones que posteriormente se quieran implementar se harán solo una vez, evitando fallos y malfuncionamientos.

De resultas de estas dos características se acostumbra a tener un código más pequeño y con menos llamadas innecesarias a funciones, haciendo que el rendimiento de nuestra aplicación también aumente por este motivo.

A modo de ejemplo, imaginemos que tenemos una zona de código donde hemos detectado un cuello de botella. El problema en concreto reside en que tenemos una función que nos devuelve el modulo 10 del número que le pasamos como parámetro (recordad que solo es un ejemplo) y ha de hacerlo muchas veces y mur rápido para cumplir con las especificaciones que nos piden.

int mod_original(int i)
{
return (i%10);
}

Un programador con algo de experiencia sabe que según que operaciones matemáticas cuestan lo suyo por lo tanto, aunque sea una guarrada se podría implementar un vector precalculado o un switch con  todos los casos que nos afecten para no tener que realizar la operación costosa que nos atañe. El código de ejemplo sería este (seguid recordando que esto es un ejemplo :) )

int mod_switch(int i)
{
switch(i)
{
case 0: return 0;
case 1: return 1;
case 2: return 2;
case 3: return 3;
case 4: return 4;
case 5: return 5;
case 6: return 6;
case 7: return 7;
case 8: return 8;
case 9: return 9;
case 10: return 0;
default: return 0;
}
}

Con esta guarrada conseguiriamos mejorar algo el rendimiento, pero a coste de empeorar muchisimo la mantenibilidad.Aparte estariamos introduciendo un error ya que si en el futuro quisieramos usar esta función para calcular el modulo 10 de un número superior a 10 no funcionaria correctamente(default: return 0;). Entonces es cuando un segundo programador con más experiencia o más sentido común debería decir que ese tipo de guarradas no deberían hacerse y buscar otra solución. Al darle vueltas al problema no solo encontraría una forma mejor de implementar la función sino que se daría cuenta que el coste real no estaría en la operación de módulo sino en la llamada a la función, así que propondría una solución como la siguiente:

#define mod_opt(i) (i%10)

Cúal de los tres códigos es el más legible? Y cual el más eficiente?

En cuanto a legibilidad, para mi el tercero (aunque entiendo que es una cuestión personal) y en cuanto a eficiencia, os dejo los valores reales de la ejecución de los tres códigos:

Original:
Case:0 Milis:34
Case:1 Milis:30
Case:2 Milis:29
Case:3 Milis:30
Case:4 Milis:30
Case:5 Milis:30
Case:6 Milis:30
Case:7 Milis:30
Case:8 Milis:34
Case:9 Milis:34
Switch:
Case:0 Milis:29
Case:1 Milis:29
Case:2 Milis:28
Case:3 Milis:29
Case:4 Milis:28
Case:5 Milis:29
Case:6 Milis:28
Case:7 Milis:29
Case:8 Milis:29
Case:9 Milis:28
Opt:
Case:0 Milis:3
Case:1 Milis:3
Case:2 Milis:3
Case:3 Milis:3
Case:4 Milis:3
Case:5 Milis:3
Case:6 Milis:3
Case:7 Milis:3
Case:8 Milis:4
Case:9 Milis:4

Creo que no hace falta decir que en este caso , como en otros muchos, mejorar la legibilidad y la mantenibilidad del código no solo son buenas prácticas de programación sino que permiten mejorar el rendimiento de nuestras aplicaciones de forma directa.

Espero que os haya gustado.

Si tenéis dudas no tengáis reparos hacérmelas llegar.

Nos vemos


LordPakusBlog

viernes, 5 de agosto de 2011

Curso de programación: Capítulo 7. Introducción al ensamblador SIMD-SSE


El capitulo de hoy trata sobre las instrucciones de ensamblador SSE. Toda la información la he sacado de mi PFC que para más información lo podeís obtener de http://upcommons.upc.edu/pfc/handle/2099.1/10988 . Espero que os guste.

Hace unos años, los fabricantes de procesadores, debido a que los problemas a resolver por el cálculo computacional eran cada vez de mayores dimensiones y que la tecnología de fabricación de chips permitía una mayor densidad de transistores, optaron por proveer a los procesadores de instrucciones paralelas. Estas instrucciones tienen la particularidad de poder operar con un cierto conjunto de datos a la vez usando una sola instrucción.
En la siguiente figura puede verse el esquema básico de funcionamiento de una instrucción
SIMD donde X[1..4] e Y[1..4] son los vectores con las variables a tratar y OP es una
operación genérica (suma, resta , multiplicación, división, etc.….):

Dependiendo del fabricante y del propósito del procesador cada juego de instrucciones
tiene sus particularidades. Así , en los PowerPC estas instrucciones son llamadas AltiVec / VMX (VMX es el nombre que le dio IBM y Altivec el nombre comercial que le dio Motorola), de la misma manera que en los procesadores Sparc se bautizaron con el nombre de Visual Instruction Set (VIS) o en el caso de los Alpha cuyas instrucciones vectoriales fueron llamadas Motion Video Instructions (MVI), todo esto solo por poner ejemplos debido a que hay innumerable cantidad de conjuntos de instrucciones SIMD (MAX, MDMX, ASP, etc…) . Todos estos conjuntos de instrucciones son ejemplos de paquetes de instrucciones SIMD usadas en el cálculo computacional en concreto y en la industria del software en general.(por ejemplo en los videojuegos)

Para el caso concreto que nos interesa (SSE de Intel) el número de operaciones depende del tipo de datos con el que se trabaje.

Con el tiempo han ido evolucionando apareciendo nuevas versiones con más caracteristicas. Graficamente podreis ver esta evolución en este esquema:

Las operaciones SSE más tipicas que se pueden utilizar en el desarrollode un proyecto son las siguientes:
Movimiento de datos: MOVSS y MOVAPS, MOVUPS, MOVLPS, MOVHPS,
MOVLHPS, MOVHLPS
Operaciones aritméticas: ADDSS, SUBSS, MULSS, DIVSS, RCPSS,
SQRTSS, MAXSS, MINSS, RSQRTSS y ADDPS, SUBPS, MULPS, DIVPS,
RCPPS, SQRTPS, MAXPS, MINPS, RSQRTPS
Comparación: CMPSS, COMISS, UCOMISS y CMPPS
Movimientos intraregistro: SHUFPS, UNPCKHPS, UNPCKLPS
Conversión de tipos de dato: CVTSI2SS, CVTSS2SI, CVTTSS2SI y
CVTPI2PS, CVTPS2PI, CVTTPS2PI
Opeaciones lógicas bit a bit: ANDPS, ORPS, XORPS, ANDNPS


Como usar las instrucciones SSE dentro de nuestro código?
Depende del compilador.. yo os lo explicaré como funciona para Visual Studio (C++) pero para todos los compiladores funciona más o menos igual.

Antes que nada, el mundo al revés, un ejemplo:
inline void Diagonal3p(TYPE ****mat, int i)
{
int ii,jj;
int ind;
TYPE **A = mat[0][i];
TYPE* x0 = (TYPE*) &A[0][0];
TYPE* x1 = (TYPE*) &A[1][0];
TYPE* x2 = (TYPE*) &A[2][0];

__asm //Calculamos los elementos de la primera fila
{
mov edx, x0
mov edi, x1
movups xmm0, [edx] //Cargamos el primer vector
movups xmm1, [edi] //Cargamos el segundo vector
movups xmm4, xmm0 //Copiamos
movups xmm5, xmm0 //Copiamos
movups xmm6, xmm0 //Copiamos
shufps xmm4, xmm4, 0x00 //Broadcast
shufps xmm5, xmm5, 0x55 //Broadcast
shufps xmm6, xmm6, 0xAA //Broadcast
mulps xmm4, xmm0 //xmm4 *= xmm0
mulps xmm5, xmm0 //xmm5 *= xmm0
mulps xmm6, xmm0 //xmm6 *= xmm0
movups xmm2, xmm1 //Copiamos
movups xmm3, xmm1 //Copiamos
movups xmm0, xmm1 //Copiamos
shufps xmm2, xmm2, 0x00 //Broadcast
shufps xmm3, xmm3, 0x55 //Broadcast
shufps xmm0, xmm0, 0xAA //Broadcast
mulps xmm2, xmm1 //xmm7 *=xmm1
mulps xmm3, xmm1 //xmm7 *=xmm1
mulps xmm0, xmm1 //xmm7 *=xmm1
addps xmm4, xmm2 //xmm4 += xmm7
mov edi, x2
addps xmm5, xmm3 //xmm5 += xmm7
movups xmm2, [edi] //Cargamos el tercer vector
addps xmm6, xmm0 //xmm4 += xmm7
movups xmm0, xmm2 //Copiamos
movups xmm1, xmm2 //Copiamos
movups xmm7, xmm2 //Copiamos
shufps xmm0, xmm0, 0x00 //Broadcast
shufps xmm1, xmm1, 0x55 //Broadcast
shufps xmm7, xmm7, 0xAA //Broadcast
mulps xmm0, xmm2 //xmm7 *=xmm1
mulps xmm1, xmm2 //xmm7 *=xmm1
mulps xmm7, xmm2 //xmm7 *=xmm1
addps xmm4, xmm0 //xmm4 += xmm7
addps xmm5, xmm1 //xmm4 += xmm7
addps xmm6, xmm7 //xmm4 += xmm7
}

A = mat[i][i];
x0 = (TYPE*) &A[0][0];
x1 = (TYPE*) &A[1][0];
x2 = (TYPE*) &A[2][0];
__asm
{
mov edx, x0
movups xmm0, [edx]
subps xmm0,xmm4
movups xmm1,xmm0
shufps xmm1,xmm1,0x00
sqrtps xmm1,xmm1
divps xmm0,xmm1
movups xmm4,xmm0 //xmm4 = A[0][0..4] ya calculado
movups [edx],xmm0
mov edx, x1
movups xmm0, [edx]
movups xmm3,xmm4
shufps xmm3,xmm3,0x55
mulps xmm3,xmm4
subps xmm0,xmm3
subps xmm0,xmm5
movups xmm1,xmm0
shufps xmm1,xmm1,0x55
sqrtps xmm1,xmm1
divps xmm0,xmm1
movups xmm5,xmm0 //xmm5 = A[0][0..4] ya calculado
movups [edx],xmm0
mov edx, x2
movups xmm0, [edx]
movups xmm3,xmm4
shufps xmm3,xmm3,0xAA
mulps xmm3,xmm4
subps xmm0,xmm3
movups xmm3,xmm5
shufps xmm3,xmm3,0xAA
mulps xmm3,xmm5
subps xmm0,xmm3
subps xmm0,xmm6
movups xmm1,xmm0
shufps xmm0,xmm0,0xAA
sqrtps xmm0,xmm0
divps xmm1,xmm0
movups [edx],xmm1
}

POR AQUÍ SEGUIRIA EL CÓDIGO PERO TAMPOCO NOS IMPORTA

Si os fijais, para implementar SSE solo teneis que seguir unos sencillos pasos:
  1. Escribir dentro de la etiqueta _asm
  2. movups. Para mover datos
  3. shufps para mover datos dentro de un mismo registro
  4. mulps . Para multiplicar
  5. divps. Para dividir
  6. addps Para sumar
  7. subps. Para restar
  8. sqrtps . Para realizar raíces cuadradas
  9. De normal se tiene un mínimo de 8 registros de 128bits(4 floats o ints por registro) (pueden ser más en según que condiciones, pero se pierde portabilidad)
  10. Para dudas seguir el ejemplo y buscar en la documentación de intel :D
Si os interesa el tema decidlo e intentaré hacer algún programilla que hago uso extensivo de las SSE para que veais la diferencia de rendimiento (según como esté montado puede ser de varios ordenes de magnitud de diferencia).

Espero que os haya gustado y que hayais aprendido. Si teneis dudas, preguntas o sugerencias no vacileis en hacermelas llegar. En próximos capitulos ahondaré más en el tema.

LordPakusBlog

Entradas populares