Blog de Abelardo Jara Berrocal: Ubuntu, electronica y software libre

Noviembre 30, 2007

Pasando objetos por referencia: el namespace y el registro de activación

Ante todo quiero resaltar que las consideraciones que comentare se aplican a lenguajes de programación fuertemente tipados como C++ y Java donde las variables una vez declaradas sólo pueden almacenar datos de su mismo tipo. Otro es el caso para lenguajes dinámicamente tipados como Python. Finalmente concluiremos porqué es mejor pasar objetos como parámetros por referencia.

La ilusión del procedimiento: el entorno de ejecución protegido:

En todos los lenguajes del tipo del Algol, y en esto incluimos a C, cada procedimiento crea un entorno de ejecuciòn limpio, controlado y protegido. Cada procedimiento tiene su propio y privado almacen de variables. Todas las instrucciones dentro del procedimiento puede acceder estas variables privadas o mejor llamadas locales. Cada procedimiento debe creer que esta ejecutándose sobre este entorno protegido como si fuera un programa que corre en una máquina sin ningún programa más. Pero además y en general el procedimiento recibe datos y retorna datos al procedimiento que lo llamó.

El concepto de procedimiento es la unidad base para los compiladores. En general un programa no es presentado en un solo archivo para ser compilado. Cuando hacemos un programa en C++ por ejemplo, creamos archivos de cabecera .h y archivos de implementación .cpp y cada par es compilado en instantes diferentes. El código compilado de cada par puede ser por ejemplo enlazado en una etapa posterior (linking). El concepto de procedimiento es lo que nos permite a los programadores construir programas no triviales.

Que es un namespace o espacio de nombres?

Aquí escribo una introducción a los “namespaces” (espacios de nombres) y “activation records” (registros de activación) que se ven en C++.

La mayoría de los lenguajes de programación basados en procedimientos como C++ le brindan al programador control sobre que variables puede leer y escribir.

Fortran, uno de los lenguajes más antiguos, definía solo 2 namespaces, un namespace global conformado por las variables globales y un namespace local dentro de cada procedimiento.

En general el manejo de namespaces es mas complejo en lenguajes de programación más recientes, dado que un procedimiento puede llamar a otro procedimiento o incluso a sí mismo (recursividad).

De esta manera un programa puede contener múltiples namespaces. El lenguaje define reglas que determinarán a que variables cada procedimiento puede acceder. Estas reglas se llaman “reglas de ámbito (scoping rules)”.

El lenguaje C tiene reglas de ámbito bastante complejas. Primero, el crea un espacio global de nombres que contiene todos los nombres de los procedimientos así como todos los nombres de las variables globales. Este nivel se llama ámbito a nivel de archivo. Todos los nombre en este nivel están declarados con el atributo “static”, por tanto ellos son visibles a cualquier procedimiento en el archivo.

A su vez cada procedimiento puede crear su propio namespace para definir sus variables y parámetros.

Y que es un “activation record” o registro de activación?

Cada procedimiento al ser ejecutado crea su propio nuevo espacio de nombres (namespace). Dentro de cada procedimiento, el programador puede declarar variables que no son accesibles desde fuera de este. Las variables declaradas tienen un tiempo de vida igual al tiempo de vida del procedimiento.

Para manejar este comportamiento, el compilador separa una región de memoria llamada “registro de activación” o “activation record” (AR), para cada llamada individual a un procedimiento. El AR es creado cuando el procedimiento es llamado, y es liberado (destruido) cuando el control regresa al procedimiento que lo llamo.

Pero el AR necesita también guardar el estado de las variables del procedimiento que lo llamo con la intención de crear la ilusión que el procedimiento se esta ejecutando en una máquina sola. Con este fin, se guardan en el AR el estado de los registros del sistema (para los que les gusta Assembler, eax, bax, los también llamados “archivo de registros” o “register file”) para poder reconstruir luego el estado del  procedimiento llamante.

En general un AR tiene las siguientes partes:

1. Parámetros: son los parámetros que el procedimiento que llama le entrega al procedimiento que se va a ejecutar (el AR actual pertenece al procedimiento que se va a ejecutar). En general pueden ser valores que el procedimiento llamante le da al procedimiento llamado para que opere con ellos, pero también pueden ser punteros si el procedimiento llamado recibe parámetros por referencia (caso más común en programación orientada a objetos)

2. Register Save Area (o área de salvado de registros): aquí se guardan los registros del sistema (eax, bax, etc)

3. Return value (o valor de retorno): El procedimiento debe poner aquí el valor que retorna al procedimiento que lo llamó

4. Return address (o dirección de retorno): para poder restablecer al program counter (PC) en la posición donde el procedimiento que llamaba llamó a nuestro procedimiento.

5. Access link (enlace para acceso): es una campo del AR, el cual el compilador usa para referenciar donde estan ubicados los otros campos del AR.

6. Parent access record pointer (puntero al AR del padre): este es un puntero y apunto al registro “access link” del procedimiento llamante o “padre”. Esto permite el anidamiento de procedimientos (esto es lo que ocurre en general en nuestros programas, desde un procedimiento A llamamos a otro procedimiento B y desde ese procedimiento B llamamos a otro procedimiento C, y así sucesivamente)

7. Locales: Aquí se almacen las variables locales del procedimiento y los punteros a las variables dinámicamente creadas. Las variables dinámicamente creadas son aquellas que no conocemos en tiempo de compilación (no sabemos si las usaremos o que tamaño en bytes tendrán al momento de compilar nuestro programa). Las variables dinámicamente creadas son las que creamos con la instrucción malloc en C o con las palabra reservada new en C++.

Las variables dinámicamente creadas tienen sus punteros en el AR pero su estructura de datos con sus valores son dispuestas en la pila (también llamada “heap”) y su contenido es liberado automáticamente cuando el puntero que la apuntaba es liberado explícitamente (por ejemplo con una instrucción “delete” en C++, siempre y cuando no hayan más punteros apuntando a la misma data) o automáticamente mediante un “colector de basura” o “garbage collector” en Java.

Ahora, donde almacenamos todos los “activation records” que vamos creando en nuestro programa? Generalmente los activation records de nuestro programa en un momento determinado de su ejecución se almacenan en el “stack” o “pila” . El procedimiento actualmente en ejecución tiene su AR en el tope de esta pila o “stack”. Cada vez que llamamos un procedimiento ponemos su AR en el tope del “stack”. Este paso de poner su AR puede ser hecho por el procedimiento que lo llamó o por el procedimiento llamado por sí mismo. Con este esquema es fácil deducir que el procedimiento padre de todos tendrá su AR en el fondo del stack.

El stack no debe ser confundido con el “heap”, la cual es utilizada principalmente para almacenar estructuras de datos creadas dinámicamente.

Si observamos ahora de nuevo la estructura del activation record (AR) veremos que se reserva espacio de memoria para cada parámetro que el procedimiento recibe. Si el procedimiento recibe un objeto cuya estructura de datos es compleja como parámetro pasado “por valor” y no “por referencia”, entonces el AR tendrá que crear dentro de él una copia del objecto con todos los datos internos que el objeto tenga. Por eso es que actualmente en programación orientada a objetos los parámetros se pasan por referencia, de modo que ahorramos en tiempo de ejecución (no es necesario copiar los datos del objeto o estructura) y en memoria (solo almacenamos un puntero en el AR). Este es el esquema por defecto en Java, donde los parámetros son pasados por referencia.

Bueno, espero que esta introducción sea de utilidad para todos. Muchos saludos.

Introducción al profiling en Linux y Cygwin

Archivado en: Programacion C++ en Linux, Programacion C++ en Windows — Abelardo Jara @ 3:58 pm

Hacer profiling de nuestras aplicaciones en C++ en Linux o en Windows (usando las GNU classpath del Cygwin) es sumamente importante si deseamos reducir el tiempo de ejecución de nuestro programa.

Mediante su uso podemos saber en qué función estamos perdiendo el tiempo. Muchas veces lo hacemos en una función tonta. Nuestra estructura de datos podría ser la mejor pero la forma en que la manipulamos podría no ser eficiente.

Por ejemplo una simple función para filtrar valores de una matriz para pasarlos a otra matriz, puede hacernos perder tiempo considerablemente. Por ejemplo una función tan tonta como:

void damePixels (char *bit, int *pixel, int nPuntos)
{
int i;
for (i=0; i<nPuntos; i++)
{
if (bit[i] == 1)
pixel[i] = negro;
else
pixel[i] = blanco;
}
}

Podría ser la causa de nuestra pérdida de tiempo.

El profiling de nuestra aplicación no sólo es de utilidad para aplicaciones de alta performance sino también en aplicaciones que corren en sistemas embebidos como el procesador Microblaze de Xilinx que esta disponible en los Virtex 4. Estos procesadores tienen una frecuencia de reloj muy limitada comparada a un Pentium 4, así que ahorrar valiosos ciclos de reloj viene muy bien a la aplicación. Y una ventaja es que las herramientas de desarrollo para estos procesadores embebidos también son las GNU Classpath (GCC, G++)

Hacer profiling también es válido para aplicaciones que se ejecutan sin terminar (por ejemplo un servicio), en estos casos nuestro programa debe poder capturar la señal de CTRL+C  (o enviada con un “kill -s … PID”) para hacer un exit( ) en condiciones.

Aquí les adjunto un tutorial de introducción que tomo de: http://www.chuidiang.com/clinux/herramientas/profiler.php

A veces usamos algoritmos más o menos complejos y nuestro programa va demasiado lento, tenemos que depurarlo y acelerarlo como sea.

No es una buena técnica ir a lo loco a mejorar el código. Podemos pasarnos dos días intentando mejorar el código de la función que creemos que pierde el tiempo, probar, y ver que todo sigue igual de lento. El tiempo a lo mejor se está perdiendo en otra función que ni siquiera se nos pasa por la cabeza, quizás sea una función muy simple, pero se la llama muchísimas veces.

Linux proporciona varias herramientas que nos permiten analizar nuestro programa en ejecución y ver en qué gasta el tiempo( prof, gprof, monitor, etc). Aquí vamos a echarle un ojo a gprof.

En primer lugar, vamos a hacer un programita que llamaremos pprofiler.c y que únicamente se dedique a perder tiempo. Puede ser algo así como:

void funcion1(); /* prototipo de una de las funciones */
void funcion2(); /* protoripo de la otra función */

/* Programa principal.
* Llama 1000 veces a cada una de las funciones funcion1() y funcion2() */
main()
{
int i;
for (i=0; i<1000; i++)
{
funcion1();
funcion2();
}
}

/* funcion1(). Pierde el tiempo */
void funcion1()
{
int i,j;
for (i=0;i<10;i++)
for(j=0;j<1000;j++);
}

/* funcion2(). Pierde más tiempo */
void funcion2()
{
int i,j;
for (i=0;i<1000;i++)
for (j=0;j<1000;j++);
}

Como puedes ver, simplemente hay dos funciones con unos bucles para perder el tiempo. En la realidad, estas y otras como estas serían tus funciones con tu código y tus algoritmos.

Para usar gprof, lo única que hay que hacer es compilar con la opción -pg y ejecutar el programa

$ cc -pg pprofiler.c -o pprofiler
$ ./pprofiler

Cuando termine de ejecutarse el programa, se generará automáticamente un fichero gmon.out, que es el fichero en el que se guardan las estadísticas de tiempo de nuestro programa y de nuestras funciones. Es importante dejar que el programa termine de forma natural (sin abortarlo), ya que si no este fichero gmon.out puede quedar incompleto y no ser de utilidad. Si nuestro programa es de los que debe estar permanentemente arrancado y no salir nunca, debemos ponerle una opción para salir. Podemos, por ejemplo, capturar la señal de ctrl-c para hacer un exit() en condiciones.

Una vez que tenemos el fichero, con el comando gprof miprograma, obtendremos en pantalla las estadísticas de tiempo. Para el ejemplo concreto, yo he obtenido lo siguiente:

$ gprof pprofiler
Flat profile:
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls us/call us/call name
99.41 10.09 10.09 1000 10090.00 10090.00 funcion2
0.59 10.15 0.06 1000 60.00 60.00 funcion1
…. aqui va una explicación de lo que es cada columna … br/> granularity: each sample hit covers 4 byte(s) for 0.10% of 10.15 seconds
index % time self children called name
<spontaneous>
[1] 100.0 0.00 10.15 main [1]
10.09 0.00 1000/1000 funcion2 [2]
0.06 0.00 1000/1000 funcion1 [3]
———————————————–
10.09 0.00 1000/1000 main [1]
[2] 99.4 10.09 0.00 1000 funcion2 [2]
———————————————–
0.06 0.00 1000/1000 main [1]
[3] 0.6 0.06 0.00 1000 funcion1 [3]
———————————————–
… otra explicación de lo que es cada campo …

Si tienes un ordenador más moderno que el mio (cosa bastante probable) y te salen todos los tiempos de 0.0, quizás debas hacer los bucles más largos.

En primer lugar nos aparece una tabla con las funciones de nuestro programa ordenadas de más consumidora de tiempo a menos. Las funciones que gastan tiempo 0.0 (o despreciable, como el main() en nuestro caso), no aparecen por defecto. Vemos que la funcion2() ha gastado el 99.41% del tiempo de ejecución de nuestro programa, funcion1() el 0.59% y main(), que ni siguiera aparece, un 0% (por eso no aparece).

Ha habido 1000 llamadas a cada función y mientras que funcion2() tarda 10090 microsegundos por llamada, funcion1() sólo tarda 60 microsegundos.

En la segunda tabla que sale, tenemos además una especie de árbol de llamadas. Indica que el 100% del tiempo (10.15 segundos) se ha estado ejecutando la funcion main(). Esta función ha llamado 1000 veces a funcion1() y otras 1000 veces a funcion2(). También están los tiempos empleados en estas llamadas.

Luego aparece funcion2(), en la que se ha estado el 99.4% del tiempo y que ha sido llamada por main() 1000 veces.

etc, etc.

Todo esto nos facilita el saber exactamente que función debemos depurar. Debemos ir en primer lugar a la primer función que aparece en la primera tabla. Ver si se la ha llamado muchas veces o si gasta mucho tiempo en cada llamada. En función de eso, debemos tratar de llamarla menos veces o bien hacer que tarde menos.

Blog de WordPress.com.