Thursday, January 13, 2011

Python (fácil) + C (rápido) = Extendiendo con C


1.- Introducción/Motivación

Es bien sabido que cualquier código de programación semi-interpretado como Python es fácil de usar, pero para iteraciones demasiado grandes, mucha gente alega de que son un poco lentos. Lentos...¿comparados con qué? Bueno, cuando hablamos de lento, siempre se nos viene a la mente el nunca mal ponderado C. C es conocido por ser un lenguaje "básico", pero muy rápido para iterar. Hoy explicaré, más o menos, como unir ámbos: escribir el código en Python, hacer los cálculos con C.

La idea básica (o más bien, como yo lo entendí) es la siguiente: necesitamos crear, primero que todo, un programa en C que podamos llamar desde Python. Por un lado, sabemos que Python trabaja con objetos, mientras que C trabaja con algo similar, pero nunca tan genial, llamadas estructuras. El truco está, justamente, en que C maneje estructuras para manejar nuestros datos, mientras que Python use clases/objetos para manejar los mismos: si hacemos que estos se entiendan de alguna manera, lograremos nuestro objetivo...¡trabajar con ámbos! Organizando un poco más, queremos:

Paso 1: Crear un código en Python.
Paso 2: Enviar datos a C.
Paso 3: Hacer algo con los datos.
Paso 4: Mandar algo de vuelta a Python, desde C.

El Paso 1 lo dejaremos para el final. Asumiendo que de algún modo mandamos datos a C...¡veamos cómo los recibimos!

2.- El Código en C

Supongamos que queremos enviar un vector de datos y un entero que nos da las dimensiones del vector de datos de Python a C y retornar su promedio. Consideren entonces el siguiente código en C que yo llamé "FuncionesDeNes.c":

1 . #include < Python.h >


2 . #include < numpy/arrayobject.h >
3 . #define ARRAYD(p) ((double *) (((PyArrayObject *)p)->data))
4 .
5 . static PyObject *FuncionesDeNes_Promedio(PyObject *self,PyObject *args){
6 .
7 . int i,n_datos;
8 . double promedio,suma=0;
9 . double *vector_en_C;
10. PyObject *vector_de_python;
11. PyArg_ParseTuple(args,"Oi",&vector_de_python,&n_datos);
12. vector_en_C = ARRAYD(vector_de_python);
13.
14. for(i=0;i < n_datos;i++){
15. suma=vector_en_C[i]+suma;
16. }
17.
18. promedio=suma/(double)n_datos;
19.
20. return Py_BuildValue("d",promedio);
21. }
22.
23. static PyMethodDef FuncionesDeNesMethods[] = {
24. {"Promedio", FuncionesDeNes_Promedio, METH_VARARGS, "¿que hace esto?"},
25. {NULL, NULL, 0, NULL}
26. };
27.
28. void initFuncionesDeNes(void){
29. (void) Py_InitModule("FuncionesDeNes", FuncionesDeNesMethods);
30. }



Lo que hace el código de arriba en C es obtener lo que enviamos desde Python (en la función FuncionesDeNes_Promedio) usando el módulo PyArg_ParseTuple, y luego regresa un double con el promedio calculado usando el módulo Py_BuildValue. Es así de simple. Las demás funciones, como se pueden observar, son definiciones para que se pueda "comprender" lo que estamos haciendo a nivel de C.

Nótese lo que sucede en las líneas 10 hasta la 12:

10. PyObject *vector_de_python;
11. PyArg_ParseTuple(args,"Oi",&vector_de_python,&n_datos);
12. vector_en_C = ARRAYD(vector_de_python);
¿Qué hicimos? Lo que tuvimos que definir en la línea 10 fue un puntero tipo PyObject, que es un tipo de estructura definida en el header . Esto define el vector que en Python es un objeto a una estructura en C. Lo que hace justamente PyArg_ParseTuple es recibir la localización de este vector en memorioa (&vector_de_python), asi como también la localización del entero n_datos (&n_datos), que es el número de datos que enviamos desde Python. Fíjense que esta función tiene tres inputs:

11. PyArg_ParseTuple(args,"Oi",&vector_de_python,&n_datos);
El primero (args) son los argumentos que recibe la función. El segundo input ("Oi") lo que hace es decirle a C qué estoy enviando: "O" es para objetos, "i" es para enteros, "d" es para doubles, etc. Si mandara 5 doubles más, por ejemplo, tendría que poner "Oiddddd". Finalmente, los ultimos inputs (&vector_de_python,&n_datos) lo que entregan son los valores a los que asociaremos lo que se envio desde Python: ¡siempre hay que definirlos antes! Ya sean objetos, enteros, doubles, pájaros, ramas, etc. Nótese que el módulo en la línea 20:
20. return Py_BuildValue("d",promedio);
Que es el convertidor desde C a Python, funciona de la misma manera. El paso siguiente es convertir esta estructura a un array en C, para que lo podamos ocupar. Nótese que en la línea 3 hicimos un define de la forma:

3 . #define ARRAYD(p) ((double *) (((PyArrayObject *)p)->data))

Es decir, el módulo ARRAYD(puntero) convierte justamente este puntero PyObject a un array en C para que lo podamos ocupar. La "flecha" lo que hace es asociar un puntero a un miembro de una estructura de la forma:

Puntero -> Miembro de una Estructura

En este caso, apuntamos al miembro "data" que es un miembro de la estructura PyArrayObject (que es lo que justamente estamos enviando, un objeto que es un array en Python: el vector que contiene los datos).

3.- Ok, ¿cómo lo llamo desde Python?
Lo primero que tenemos que hacer es un módulo que contenga este código en C y que desde Python simplemente "compilemos" el programa. Para ello, la solución es hacer un archivo setup.py como el siguiente:

1. from distutils.coreimport setup, Extension
2.
3. module =Extension('FuncionesDeNes', sources = ['FuncionesDeNes.c'])
4. setup(name = 'Funciones creadas por Nestor: Extensiones de C/Python', version = '1.0', ext_modules = [module])

El código es bastante estándar. Hay que tener cuidado con "sources", que debe tener el nombre del archivo .c en el que metimos el código de más arriba. Warning: Hay un pequeño detalle con el código de arriba, eso si. Si eres científico, probablemente trabajarás con gsl, la librería matemática, etc. y por tanto necesitas compilar esas librerías también. Para agregar las librerías a compilar simplemente despues de sources agregamos una sección "libraries" del mismo modo que sources y escribimos las librerías separadas por comas. Por ejemplo, yo uso GSL y la librería matemática en mis códigos (exceptuando en el de más arriba, lógicamente), asi que mi setup.py se ve así:


1. from distutils.coreimport setup, Extension
2.
3. module =Extension('MiModulo', sources = ['MiModulo.c'],libraries=['gsl','gslcblas','m'])
4. setup(name = 'Funciones creadas por Nestor: Extensiones de C/Python', version = '1.0', ext_modules = [module])

De ese modo, al compilar, también se compilaran las librerías del modo "-lgsl -lgslcblas -lm" (nótese que todas las librerías son lnombre, donde "nombre" es el nombre de la librería. Python sabe eso, asi que basta con pasarle "nombre" simplemente: ¡la serpiente se encargará de agregar la "l" y la rayita!). Ahora desde consola, con el código C en el mismo directorio hacemos:

# python setup.py build

Eso creará una carpeta "build" en nuestro directorio, y dentro una carpeta llamada "lib.linux-iVersion-Version2" donde Version es la versión de nuestro SO y Version2 es la versión de Python que ocupamos para compilar. Dentro de la carpeta

"lib.linux-iVersion-Version2" tendremos un archivo .so (en mi caso "FuncionesDeNes.so"). Ese lo movemos simplemente a la carpeta en donde tenemos nuestro archivo en Python desde el cual queremos ocupar el código...¡ahora ocupémos nuestro módulo recién creado!





4.- Ok, ¿cómo lo llamo desde Python? (¿ahora si?)







Ok, ok, tranquilos. La paciencia es la clave del éxito (?). Consideren el siguiente código en Python:





1. # -*- coding: utf-8 -*-
2. from numpy.randomimport *
3. importFuncionesDeNes
4.
5. Datos=randn(10000000,1)
6. printFuncionesDeNes.Promedio(Datos,len(Datos))

Hay tres cosas claves en este código. Primero...¿qué hace randn(10000000,1)? Es simplemente una función de numpy.random que genera 10000000 (10^7) números, todos con una distribución normal estándar (media 0, desviación estándar 1) en un arreglo numpy. Así, Datos[0] entrega el primer número, Datos[1] el segundo, etc. Obsérvese que en la línea 3 simplemente importamos el módulo y en la línea 6 lo usamos, pasándole los inputs que habíamos dicho...¡y eso es todo!




5.- Ok, se ve lindo y todo pero...¿es necesario?








¡Es la misma pregunta que me hize yo!, ¿realmente valdrá la pena? Pues los invito a realizar el mismo código en Python (es decir, hacer la iteración de los 10^7 números con un "for" desde Python). En mi caso, desde mi notebook, Python tomó entre 55 y 57 segundos en entregarme el valor del promedio...por otro lado, ocupando este método, C se demoró entre 0.04 y 0.05 segundos: IN-CRE-ÍBLE, pero cierto. Inténtenlo, se van a sorprender.

¡Saludos!