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

sábado, 17 de octubre de 2009

Clases que "desaparecen" luego de compilar

La magia de C++ es que, una vez compilado el código, algunas clases pueden desaparecer por completo (principalmente las que se usan en stack). O sea, aunque las clases abstraen al programador de los detalles de implementación, al final, el código termina siendo tan óptimo como si la clase no fuera utilizada en un principio.

Un ejemplo. Teniendo la siguiente clase Acumulador:
#include <cstdio>

class Acumulador {
  int v;
public:
  Acumulador()         { v = 0;                   }
  ~Acumulador()        { std::printf("%d\n", v);  }
  void acumular(int x) { v += x;                  }
};
Un código como el siguiente:
{
  Acumulador acum;
  acum.acumular(2);
  acum.acumular(4);
  acum.acumular(10);
}
Al compilarlo (optimizándolo), el código equivale a exactamente esto:
{
  int v = 0;
  v += 2;
  v += 4;
  v += 10;
  std::printf("%d\n", v);
}
La clase Acumulador ya no existe. Obtenemos el código más óptimo posible: sin llamadas a la función "acumular", ni ningún byte extra de memoria (Acumulador ocupa lo mismo de memoria que ocupa un "int").

Este ejemplo no ayuda a ver grandes ventajas, pero si el constructor y el destructor hacen tareas complicadas, y las funciones miembros también, el resultado puede llevarnos a dos puntos:
  • Nos abstrae de la complejidad de la implementación (e.j. para qué quiero saber cómo se acumula si sólo quiero acumular)
  • Obtenemos código tan óptimo como si no hubiéramos usado la abstracción (e.j. las operaciones se acercan al hardware tanto como sea posible).

martes, 19 de agosto de 2008

¿Preincrementar o postincrementar?

Hace mucho que quería escribir un post tan inútil como este. La duda que siempre me pasaba por la cabeza era la siguiente: ¿Será exactamente el mismo código ensamblador el que genera el compilador cuando utilizamos el preincremento o el postincremento (sin utilizar el valor de retorno)? Tenía la seguridad de que así debía ser (una optimización tan básica no podía ser dejada de lado), pero me faltaban las pruebas (en ensamblador) para verificarlo.

Recordando un poco, el preincremento
int a = 0;
int b = ++a;
hace a=1 y b=1, mientras que el postincremento
int a = 0;
int b = a++;
hace a=1 y b=0, esto significa que b obtuvo el valor de a anterior al incremento.

Aquí estamos utilizando el valor de retorno del operador incremento. El código ensamblador es distinto para cada caso. Vamos a echarle una mirada (antes le recomiendo ver este excelente artículo para comprender más sobre el stack y las convenciones de llamadas). En el preincremento el código ensamblador (generado por GCC 3.4.5 para i386) es:
subl  $8, %esp          // reservamos 8 bytes en el stack (para variables locales)
movl  $0, -4(%ebp)      // a   = 0
leal  -4(%ebp), %eax    // eax = &a
incl  (%eax)            // *eax= (*eax) + 1
movl  -4(%ebp), %eax    // eax = a
movl  %eax, -8(%ebp)    // b   = eax
En el postincremento
subl  $8, %esp          // reservamos 8 bytes en el stack
movl  $0, -4(%ebp)      // a   = 0
movl  -4(%ebp), %edx    // edx = a
leal  -4(%ebp), %eax    // eax = &a
incl  (%eax)            // *eax= (*eax) + 1
movl  %edx, -8(%ebp)    // b   = edx
donde se ve que el registro edx se utiliza para guardar el valor que tenía a antes del incremento para luego asignárselo a b.

Pero la pregunta original es, ¿qué pasa si no usamos el valor de retorno? ¿el código de ++i o i++ es igual? Debería serlo, y de hecho, lo es. Pero como veremos más abajo, esto sólo se cumple si utilizamos tipos de datos built-in (int, double, long, etc.). Miremos el siguiente código:
void func() { }
void pre () { for (int c=0; c<8; ++c) { func(); } }
void post() { for (int c=0; c<8; c++) { func(); } }
int main()
{
  pre();
  post();
  return 0;
}
Básico, un for pero con las dos variantes posible de incremento. El código generado para ambos casos (tanto para la función pre como post) es el siguiente:

  pushl %ebp              // guardar el viejo puntero a la base del stack
  movl  %esp, %ebp        // establecer la nueva base del stack
  subl  $4, %esp          // guardar 4 bytes en el stack (para variables locales)
  movl  $0, -4(%ebp)      // c = 0
L3:
  cmpl  $7, -4(%ebp)
  jg    L2                // si c > 7 entonces ir a L2
  call  _func             // llamar a la función func()
  leal  -4(%ebp), %eax    // eax = &c
  incl  (%eax)            // *eax= (*eax) + 1
  jmp   L3                // repetir yendo a L3
L2:
  leave                   // restaurar la base del stack (popl %ebp)
  ret                     // retornar al punto de llamada
Realmente es indiferente usar cualquiera de los dos tipos de incremento, salvo, en los tipos definidos por el usuario. C++ ofrece un soporte para tipos de usuario igual a los built-in (bueno, no del todo, pero lo están solucionando). Nos da la posibilidad de sobrecargar los operadores de nuestros propios tipos (clases). Por ejemplo:
class tipo {
  int x;
public:
  tipo(int y) : x(y) { }
  tipo(const tipo& y) : x(y.x) { }
  // preincremento
  tipo& operator++() {
    ++x;
    return *this;
  }
  // postincremento
  tipo operator++(int) {
    tipo tmp(*this);
    ++x;
    return tmp;
  }
  bool operator<(int y) const { return x < y; }
};

void func() { }
void pre()  { for (tipo c=0; c<8; ++c) { func(); } }
void post() { for (tipo c=0; c<8; c++) { func(); } }

int main()
{
  post();
  return 0;
}
No colocaré el código ensamblador por desbordar belleza, pero el código generado en este caso es distinto: cada incremento llama a la función correspondiente a su operador. Esto es debido a que ambas implementaciones varían considerablemente. Por ejemplo, el postincremento necesita de una instancia extra de tipo para poder devolver el anterior valor de this, en cambio, el preincremento devuelve una referencia al mismo this (sin necesidad de hacer una copia).

Como dato curioso, algo interesante ocurre al utilizar las optimizaciones del GCC. Si compilamos este último programa con el parámetro -O3, vamos a ver que todas las funciones correspondientes a la clase tipo desaparecen, y todo el código resultantes es inline, dando como resultado un código tan óptimo como si hubiéramos utilizado un int en vez de nuestra clase tipo.

¿Cómo obtengo el código ensamblador desde un archivo C/C++?
Con el compilador gcc, hay que utilizar el parámetro -S:
g++ -S -o archivo.s -c archivo.cpp
En archivo.s queda el código ensamblador (sintaxis AT&T).

sábado, 1 de marzo de 2008

Medir el tiempo de una rutina

¿Alguna vez se preocupó por la velocidad con la que corre su programa? ¿No? Entonces usted es un candidato perfecto para jugar al Java. En caso contrario, voy a explicarle un pequeño código que utilizaremos en próximas entregas para medir el tiempo de ejecución de determinadas rutinas.

Ya lo dijo alguien: "La optimización prematura es la raíz de todos los males". No hay nada más cierto, aunque también es verdad que hacer algo simple de la peor forma posible, es la causa de otros grandes males. Con esto quiero decir que debería intentar hacer las cosas de la forma más simple y más óptima que usted conozca (dándole mayor importancia a la simplicidad del código); luego se preocupa por "darle velocidad" a la rutina que provoca el cuello de botella (en próximos posts veremos cómo usar gprof para detectarlo).

La forma de calcular el tiempo de CPU que toma una función es muy simple:
  • tomamos el valor del reloj antes de realizar la llamada (t_ini),
  • llamamos a la rutina en cuestión, y
  • tomamos nuevamente el valor del reloj (t_fin).
La diferencia entre t_fin - t_ini nos da el total de tiempo que tomó: 1) hacer la llamada a la rutina, 2) que esta haga su trabajo, 3) que devuelva el resultado.

Ahora hay algunos pequeños detalles de implementación. Por ejemplo, ¿qué función usar para tomar el tiempo del reloj? Y más importante, ¿qué precisión obtenemos con dicha función?

Para tomar el tiempo podemos usar la rutina clock(), que devuelve el tiempo aproximado de CPU que transcurrió desde que nuestro programa fue iniciado, dicho tiempo representado en un valor de tipo clock_t: un valor entero que indica una cantidad de "tics" de reloj.

La precisión que tenemos con dicha rutina es de CLOCKS_PER_SEC (tics de reloj por segundo), lo que significa que por cada segundo que pasa, la función clock() nos devolverá CLOCKS_PER_SEC unidades más que el valor anterior. En MinGW, CLOCKS_PER_SEC es igual a 1000, pero es mejor no fiarse de esto, ya que en otras plataformas dicho valor varía. Inclusive, según POSIX, la constante CLOCKS_PER_SEC debería ser 1000000.

Veamos algo de código:
#include <stdio.h>
#include <time.h>

int main(int argc, char *argv[])
{
  clock_t t_ini, t_fin;
  double secs;

  t_ini = clock();
  /* ...hacer algo... */
  t_fin = clock();

  secs = (double)(t_fin - t_ini) / CLOCKS_PER_SEC;
  printf("%.16g milisegundos\n", secs * 1000.0);
  return 0;
}
Con esto podemos medir cuántos milisegundos demoró "hacer algo". Todo parece muy bonito hasta que nos damos cuenta de dos grandes problemas:
  1. Tomar una medida única y aislada es igual que tomar un número completamente aleatorio y mostrarlo (no es una muestra representativa). Es mejor repetir las mediciones unas cuantas veces (y hablo del orden de las 100, o 100000, o 1e32 veces), y luego sacar un promedio de todo.
  2. La función clock() no llega a tener una precisión ni de 10 milisegundos (aunque CLOCS_PER_SEC sea 1000 o más).
Una vez dicho esto, el código de arriba no sirve ni para limpiarse los trastes... Así que tenemos que buscar una función con mayor precisión, y además, promediar varias muestras.

Existen otras alternativas como la función gettimeofday, pero bajo Windows sufre del mismo problema de precisión que clock(). Igualmente en Linux funciona perfectamente, así que vale la pena tener en cuenta este código:
#include <stdio.h>
#include <time.h>
#include <sys/time.h>

/* retorna "a - b" en segundos */
double timeval_diff(struct timeval *a, struct timeval *b)
{
  return
    (double)(a->tv_sec + (double)a->tv_usec/1000000) -
    (double)(b->tv_sec + (double)b->tv_usec/1000000);
}

int main(int argc, char *argv[])
{
  struct timeval t_ini, t_fin;
  double secs;

  gettimeofday(&t_ini, NULL);
  /* ...hacer algo... */
  gettimeofday(&t_fin, NULL);

  secs = timeval_diff(&t_fin, &t_ini);
  printf("%.16g milliseconds\n", secs * 1000.0);
  return 0;
}
Como puede ver la estructura timeval contiene dos campos, segundos y microsegundos transcurridos (tv_sec y tv_usec respectivamente), por lo tanto ofrece una precisión de microsegundos. De todas formas, como decía esto en Windows no sirve y la razón es sencilla, en la misma MSDN explican que el temporizador del sistema corre aproximadamente a unos 10 milisegundos, por lo tanto, cualquier función que lo utilice nos estará dando la misma asquerosa precisión (inclusive al utilizar GetSystemTimeAsFileTime y FILETIME). Por lo tanto la solución es utilizar lo que se conoce en el mundo de Windows como el "contador de rendimiento de alta resolución" (high-resolution performance counter):
#include <stdio.h>
#include <windows.h>

/* retorna "a - b" en segundos */
double performancecounter_diff(LARGE_INTEGER *a, LARGE_INTEGER *b)
{
  LARGE_INTEGER freq;
  QueryPerformanceFrequency(&freq);
  return (double)(a->QuadPart - b->QuadPart) / (double)freq.QuadPart;
}

int main(int argc, char *argv[])
{
  LARGE_INTEGER t_ini, t_fin;
  double secs;

  QueryPerformanceCounter(&t_ini);
  /* ...hacer algo... */
  QueryPerformanceCounter(&t_fin);

  secs = performancecounter_diff(&t_fin, &t_ini);
  printf("%.16g milliseconds\n", secs * 1000.0);
  return 0;
}
En este caso, imagine que QueryPerformanceCounter es como clock() y QueryPerformanceFrequency es como CLOCKS_PER_SEC. Es decir, la primera función nos da el valor del contador, y la segunda su frecuencia (en ciclos por segundo, hertz). Cabe aclarar que un LARGE_INTEGER es una forma de representar un entero de 64 bits por medio de una unión (union).

Como tarea al lector, si es que existe alguno, le queda hacer una versión "portable" (entre Windows y Linux) para medir el rendimiento (con unos cuantos #ifdef WIN32 y #endif sería suficiente).

18 de Marzo del 2008: acá transcribo una macro que me pasó el amigo Carlos Becker para medir el tiempo de una rutina en Linux mediante clock_gettime:
#define TIME_THIS(X) \
  { \
    struct timespec ts1, ts2; \
    clock_gettime( CLOCK_REALTIME, &ts1 ); \
    X; \
    clock_gettime( CLOCK_REALTIME, &ts2 ); \
    printf( #X " demora: %f\n", \
      (float) ( 1.0*(1.0*ts2.tv_nsec - ts1.tv_nsec*1.0)*1e-9 \
      + 1.0*ts2.tv_sec - 1.0*ts1.tv_sec ) ); \
  }

/* podemos usarla así */
{
  double x, y, z;
  x = 2.0;
  y = 4.0;
  TIME_THIS(z = sqrt(x*x + y*y));
}
Lo que da como resultado:
z = sqrt(x*x + y*y) demora: 0.015164