lunes, 3 de septiembre de 2012

En el principio fue el código máquina (I)

Por petición popular (de una sóla persona) inicio aquí una serie de entradas sobre diferentes aspectos de la optimización de programas: ¿Qué cosas funcionan y cuáles no? ¿cuándo es necesario optimizar a mano y cuándo es mejor dejar que el compilador lo haga por tí? etc. Me da que no es este un tema popular, entre otras cosas porque la historia nos dice que, debido a los cambios en el hardware, lo que era una optimización ayer, ahora resulta que es más lento que un código menos trabajado. La sensación es que el esfuerzo es fútil. De hecho ese es uno de los temas que pienso tocar en el futuro: ¿Por qué el paralelismo y las jerarquías de memoria (registros->caches->RAM->Disco) no son meros caprichos de la tecnología actual, sino que hay leyes físicas fundamentales que garantizan que vamos a tenerlas en el futuro? El tema de hoy comenzó, precisamente, como una pregunta referente a la memoria: ¿cuándo es preferible guardar unos cálculos en memoria para no tener que rehacerlos? La respuesta es muy compleja y totalmente caso-dependiente. Existen reglas generales, pero cada caso concreto tiene sus diferentes problemas. Por lo tanto, vamos a considerar el caso concreto, relacionado con un programa de ajedrez:

const int UP = 8;

inline int Sign(int color)
{ return (1 - (color * 2)); }  // White sign is +1, Black sign is -1

inline int Ahead(int color)
{ return (Sign(color) * UP); }  // UP is -8, DOWN is +8


Supongamos que la función Ahead se llama muchas veces siendo color un valor que sólo admite los valores 0 y 1. ¿Es más rápido precalcular un array ahead de dos valores?

En este caso creo que la respuesta es clara. Los cálculos son extremadamente sencillos y pueden hacerse sobre registros, así que ni hablar de precálculos. Aunque para dos únicos valores es seguro que la cache se utilizaría eficientemente, acceder a registros es más rápido que acceder a cache.

Sin embargo aún quedan algunas preguntas por responder:
  • ¿Los inline hacen algo?
  • ¿Se conseguiría alguna ganancia optimizando a mano Ahead y sign?
Para responder a esas preguntas lo mejor es recurrir al código máquina.

La solución está aquí


Para ver el código que genera el compilador escribí el siguiente programa:

int main()
{
    int b=rand() & 1;  // Me aseguro de que b=0,1

    int a=Ahead(b) ;

    printf("Hello world %i !\n", a);
    return 0;
}


El rand() es necesario porque si ponemos una constante literal el compilador no se molesta en incluir las funciones y llama a printf con el valor calculado en tiempo de compilación. El printf es necesario porque si no usamos el valor de a, el compilador no se molesta en incluir nada.

El código máquina generado por gcc con la máxima optimización (-O3), pero sin más florituras es:

sub    rsp,0x8              
call   0x400470 <rand@plt>  // Llama a rand()

and    eax,0x1              // b = rand() & 1  

    
mov    esi,0x4006bc         // N.P.I.

mov    edi,0x1              // N.P.I.

neg    eax                  // eax = -b  
add    eax,eax              // eax = eax+eax = -2*b

lea    edx,[rax*8+0x8]      // edx = 8*eax+8 = 8 - 16*b

 
xor    eax,eax              // eax = 0
call   0x400460 <__printf_chk@plt> // Llama a printf

xor    eax,eax
add    rsp,0x8
ret    
                             


La parte del programa donde se hace la "llamada" a Ahead son simplemente tres instrucciones en código máquina que realizan la operación a = 8 - 16*b.

Se pueden hacer muchos comentarios respecto al código y la cantidad de trucos que usa:

1. Se da cuenta de que la llamada combinada a las dos funciones se puede simplificar como:

Ahead(color) = (1 - (color * 2)) * UP = 8 - 16*color

2. Aprovecha que teníamos color en eax y lo suma consigo mismo para obtener el doble de una forma rápida y sin necesidad de parámetros adicionales.

3. Usa la instrucción lea (load effective address) como un truco para obtener un cálculo aritmético. Esta función suele usarse para calcular punteros y moverse por un array (índice + offset), pero aquí se usa para hacer un cálculo. Desconozco si es posible escoger cualquier multiplicador o si hay sólo unos cuantos a elegir (lo que explicaría por qué no hace simplemente [rax*16+0x8]).
 
En resumen, reto a cualquiera a que escriba un código máquina más eficiente que el que genera el compilador para este caso.

Un detalle interesante es que, en este caso, si hubiéramos usado un array, los "cálculos" necesarios para acceder a los elementos son casi igual de complicados que el cálculo en sí.

Respecto a la otra pregunta: pues no, el gcc genera el mismo código. Da igual si hemos puesto la directiva inline o no.

(Continuará)

1 comentario:

  1. Pensado en la utilidad de:

    mov esi,0x4006bc // N.P.I.
    mov edi,0x1 // N.P.I.

    pienso que deben ser la dirección del string del printf y el número de parámetros (podría comprobarlo, pero no tengo el código a mano). De todas formas, me surge la siguiente pregunta de la que quizás alguien sepa la respuesta.

    Parece claro por el código que en la librería existen versiones de printf con parámetros en los registros (junto, supongo, a las normales con push y pop). Sé cómo va eso cuando la función en cuestión está en el propio código que se compila, pero no acabo de ver cómo lo hace el compilador cuando se trata de una función en una librería. ¿Alguna idea?

    ResponderEliminar