Las GPU's han sobrepasado hace ya algún tiempo la potencia de cálculo de los procesadores convencionales. En el momento de escribir éste artículo, cualquier GPU destinada al mercado de los videojuegos puede alcanzar 60 GFlops (miles de millones de operaciones por segundo), mientras que los procesadores que se venden, alcanzan a lo sumo 12 GFlops. Es decir, tenemos un procesador 5 veces más rápido y sólo lo utilizamos para jugar. Y eso por no hablar del ancho de banda a memoria, que también es muy superior en las GPU.
Debido al alto rendimiento de las GPU, existen numerosas iniciativas para crear bibliotecas de programación genérica sobre éstas. Tal es el caso por ejemplo de libSH, que permite programar las GPU mediante el mismo código C++ con el que programamos nuestra aplicación, aunque con ciertas restricciones.
OpenGL es un lenguaje de programación gráfica que permite manipular las tarjetas aceleradoras 3D. Está implementado en forma de API en C (con wrappers para muchos otros lenguajes), y gestiona los recursos del hardware de vídeo por medio de una máquina de estados.
El subconjunto de OpenGL que se necesita para hacer programación genérica es bastante reducido, ya que gran parte del API proporciona funcionalidades que se pueden realizar manualmente. De hecho, de cara a realizar programas legibles, es mucho mejor realizarlas manualmente, aunque en ocasiones no sea tan eficiente.
Desde hace bastante tiempo, existen extensiones de nVidia que permiten programar el hardware gráfico manualmente, mediante el lenguaje Cg, para modificar el comportamiento del procesador de vértices o el procesador de fragmentos. El procesador de vértices se encarga de modificar los valores asociados a los vértices (como posición, coordenadas de textura, etc.) antes de pasarlos al módulo que los dibuja. El procesador de fragmentos se encarga de calcular el color final que debe tener cada pixel de la pantalla.
Con la aparición de OpenGL 2.0, lo que antes eran extensiones de nVidia se estandarizaron y ahora todos los fabricantes de tarjetas modernas proporcionan hardware programable mediante OpenGL estándar. Para ello, se creó un lenguaje de programación, denominado GLSL (GL Shading Language), muy parecido a C, que permite programar el procesador de vértices y el procesador de fragmentos para modificar el modo en que se comportan. Los programas por vértice y por fragmento se denominan comúnmente shaders.
Los shaders son el pilar fundamental de la programación genérica en GPU, ya que nos permiten utilizar el procesador paralelo de la tarjeta de vídeo para realizar cualquier cálculo deseado, no necesariamente relacionado con el mundo de los gráficos. La restricción es, lógicamente, que se debe alimentar la tarjeta con datos en algún formato gráfico.
El primer problema que se nos presenta a la hora de realizar programación genérica en la GPU es cómo se le hace llegar los datos de entrada, y cómo recibir los datos de salida cuando los cálculos hayan finalizado. No es algo trivial, ya que existen muchas formas diferentes de pasarle datos a la tarjeta de vídeo mediante OpenGL:

Alimentar el procesador de fragmentos con textura es muy sencillo, basta con definir un polígono, asociarle una imagen de textura con nuestros datos codificados en ella, y dejar a la tarjeta que trabaje. La tarjeta dibujará el polígono en pantalla y para cada pixel que necesite dibujar, llamará a nuestro programa por fragmento ofreciéndole la coordenada de la textura correspondiente a dicho pixel. Para que la imagen impresa en pantalla sea utilizable como información de salida, se necesitan varios requisitos:
Por tanto, si se parte un polígono rectangular con una textura, y un viewport (zona de visualización) del mismo tamaño que la textura, ya es posible enviar a la GPU un conjunto de datos de entrada y ésta producirá un conjunto de datos de salida en la pantalla, que podrán ser leídos a continuación.
Sin embargo, éste modelo es demasiado simple, y no permite realizar operaciones entre varios conjuntos de datos de entrada. Para resolver éste problema, nuevamente, existen varias opciones:
Ahora bien, ¿cómo transmitir los datos de salida de una pasada a una textura para que sean la entrada de la siguiente pasada? Pues con una técnica denominada render-to-texture (render a textura). En los últimos drivers proporcionados por los fabricantes de tarjetas ya existe una extensión de OpenGL denominada Framebuffer Objects, que permite realizar el render en una textura, en lugar de hacerlo sobre la pantalla. De ésta forma, una vez realizada una pasada, ya están en su sitio los datos de entrada para la siguiente pasada. En caso de no disponer de dicha extensión, existe otro método, consistente en copiar los datos de la pantalla sobre una textura después de terminar un render.
En ésta sección se asumirá que el lector o bien ya sabe OpenGL o bien no le interesa aprender más que lo imprescindible para poder realizar programación genérica en la GPU.
Lo primero que se debería hacer antes de afrontar un sistema de programación genérica es pensar un poco cómo estructurar el sistema, cómo organizar los datos de entrada en texturas, y cómo separar el código del programa y el código que resuelve el algoritmo mediante OpenGL. Al utilizar GLSL, el algoritmo queda bastante separado del resto del código, ya que necesariamente va en un fichero separado, pero todavía hay un cierto acoplamiento ya que tanto el programa como el shader deben estar de acuerdo en el modo en que interpretar las texturas de entrada.
La mejor forma de eliminar el acoplamiento es siempre añadir más capas de software que lo oculten, pero se deja a juicio del lector los temas relacionados con el diseño de su aplicación concreta. Aquí se mostrará un ejemplo de código que no deja de ser un ejemplo y por razones de sencillez, no se ha realizado un diseño enrevesado con múltiples capas...
El requisito fundamental a la hora de utilizar OpenGL para cualquier cosa, es disponer de un Contexto OpenGL, que consiste en general en tener una ventana asociada a OpenGL. Existen muchas bibliotecas que nos permiten hacer esto, y una de las más utilizadas por sencilla, es GLUT. El código para crear una ventana con contexto GL, es el siguiente:
glutInit(&argc,argv);
glutInitWindowPosition(0, 0);
glutInitWindowSize( 640, 480 );
glutInitDisplayMode(GLUT_RGBA | GLUT_DEPTH | GLUT_DOUBLE);
if (glutCreateWindow("Prueba") == GL_FALSE) {
printf("Error creando el contexto OpenGL\n");
exit(1);
}
Se empieza por inicializar GLUT, a continuación se establece la posición y el tamaño de la ventana, luego las propiedades del framebuffer, es decir, del área de dibujo, y por último, hay que crear la ventana con las propiedades establecidas. Llegados a este punto, ya es posible empezar a ejecutar comandos OpenGL. Por tanto, veamos cómo configurar las texturas y la extensión Framebuffer Objects:
glGenFramebuffersEXT(1, &fb);
glGenTextures(1, &front_tex);
glGenTextures(1, &back_tex);
glGenRenderbuffersEXT(1, &depth_rb);
glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, fb);
En primer lugar, hay que generar todos los elementos necesarios, el Framebuffer Object, dos texturas, un Renderbuffer, y asociar el Framebuffer creado a OpenGL. Los renderbuffer son bloques de memoria que solo sirven para que OpenGL pinte en ellos, o lea de ellos si lo necesita, pero no tienen otro cometido, y pueden ser necesarios para que el driver acepte activar el Framebuffer.
glBindTexture(GL_TEXTURE_2D, back_tex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, WIDTH, HEIGHT, 0,
GL_RGBA, GL_UNSIGNED_BYTE, NULL);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glFramebufferTexture2DEXT(GL_FRAMEBUFFER_EXT,
GL_COLOR_ATTACHMENT0_EXT,
GL_TEXTURE_2D,
back_tex, 0);
A continuación se activa una de las texturas generadas para estableces sus propiedades. En primer lugar el tamaño y la configuración de pixels, y luego el filtrado. Es muy importante utilizar filtrado nearest en programación genérica, para evitar que al leer de la textura se manipulen los datos debido a un filtro lineal, bilineal, etc. que OpenGL puede realizar automáticamente. Finalmente se asocia al framebuffer, de modo que los renders terminarán siendo dibujados en la textura.
glBindRenderbufferEXT(GL_RENDERBUFFER_EXT, depth_rb);
glRenderbufferStorageEXT(GL_RENDERBUFFER_EXT,
GL_DEPTH_COMPONENT24,
WIDTH,
HEIGHT);
glFramebufferRenderbufferEXT(GL_FRAMEBUFFER_EXT,
GL_DEPTH_ATTACHMENT_EXT,
GL_RENDERBUFFER_EXT,
depth_rb);
El siguiente paso consiste en un proceso equivalente con el renderbuffer, se asocia, se reserva tamaño y características de pixel, y por último se asocia al framebuffer para que las componentes de profundidad se dibujen en el renderbuffer. Una vez configurado el framebuffer, es imprescindible preguntar a OpenGL si está completo o no:
GLenum status = glCheckFramebufferStatusEXT(GL_FRAMEBUFFER_EXT);
if (status == GL_FRAMEBUFFER_UNSUPPORTED_EXT) {
printf("Configuración FBO no válida, prueba otra\n");
}
En caso de que no esté completo hay que probar otra configuración de profundidad de color para los buffers establecidos o añadir más renderbuffers. La configuración que se muestra aquí está probada en una nVidia Geforce 5700Ultra y en una nVidia Geforce 6600GT con los últimos drivers para Linux. Tendremos que configurar la segunda textura, aunque el proceso es análogo al anterior, si bien no hay que asociarla todavía al framebuffer.
Ahora viene la parte en que cargamos datos en la textura de entrada, y el código es similar al siguiente:
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, WIDTH, HEIGHT,
GL_BGRA, GL_UNSIGNED_BYTE, puntero_a_datos);
Donde el puntero que se muestra debe contener los datos de la textura con el formato que se indica en la llamada, color en orden BGRA (azul, verde, rojo, alfa), en componentes de un byte. La documentación de OpenGL muestra otras formas de pasar información, es posible codificar valores en punto flotante para cada componente, si se necesita más precisión.
En éste punto ya podríamos empezar a renderizar, pero para poder realizar la programación que nosotros queremos, es imprescindible cargar algún shader en el procesador de fragmentos:
GLuint shader = glCreateShader(GL_FRAGMENT_SHADER);
const char *pcode = CARGAR_CODIGO("shader.gl");
glShaderSource(shader, 1, &pcode, NULL);
glCompileShader(shader);
GLint val;
glGetShaderiv(shader, GL_COMPILE_STATUS, &val);
if (val != GL_TRUE) {
printf("Errores compilando shader.gl!\n");
char logstr[4096];
glGetShaderInfoLog(shader, 4096, NULL, logstr);
printf(logstr);
exit(1);
}
program = glCreateProgram();
glAttachShader(program, shader);
glLinkProgram(program);
glGetProgramiv(program, GL_LINK_STATUS, &val);
if (val != GL_TRUE) {
printf("Errors enlazando shader.gl!");
char logstr[4096];
glGetProgramInfoLog(program, 4096, NULL, logstr);
printf(logstr);
exit(1);
}
glUseProgram(program);
Ahora ya solo queda escribir el bucle principal de render, en el que se dibuja el polígono con la(s) textura(s) aplicada(s):
void dibujar() {
glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, fb);
glFramebufferTexture2DEXT(GL_FRAMEBUFFER_EXT,
GL_COLOR_ATTACHMENT0_EXT,
GL_TEXTURE_2D,
back_tex, 0);
glBindTexture(GL_TEXTURE_2D, front_tex);
glLoadIdentity();
glUseProgram(program);
glViewport(0,0, WIDTH, HEIGHT);
glClearColor(1,0,0,1);
glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT);
glColor4f(1,1,1,1);
glBegin(GL_QUADS);
glTexCoord2f(0,0);
glVertex2f(-1,-1);
glTexCoord2f(0,1);
glVertex2f(-1,1);
glTexCoord2f(1,1);
glVertex2f(1,1);
glTexCoord2f(1,0);
glVertex2f(1,-1);
glEnd();
glutSwapBuffers();
glutPostRedisplay();
}
Aquí se muestra un bucle básico, donde siempre se dibuja un polígono con la primera textura activa, mediante un shader, sobre la segunda textura. Se deja como ejercicio para el lector, adaptarlo a su problema concreto, posiblemente activando multi-textura para combinar ambas texturas en el resultado.
Podemos utilizar la función dibujar() directamente, sin llamar a glutPostRedisplay(), o bien dejar que GLUT realice el bucle por nosotros manteniendo esa llamada, y añadiendo ésto en el programa principal:
glutDisplayFunc(dibujar);
glutMainLoop();
Como ejemplo, os dejo un pequeño programa que realiza una operación sobre un conjunto de datos generados aleatoriamente. La operación se puede comprobar en el shader que acompaña al ejemplo.
Os recomiendo que hagáis la prueba de implementar un algoritmo similar (el algoritmo es muy sencillo) en GPU con un volumen grande de datos y echéis cuentas de cuantas veces más rápida es vuestra GPU que vuestra CPU. Recordad que éste ejemplo maneja 512*512*4 valores en cada frame...
La tecnología actual de los procesadores está acercándose cada vez más al límite en que no será posible obtener más rendimiento de una única unidad de cálculo. Es evidente que el paso lógico consiste en duplicar unidades de cálculo para obtener mayor rendimiento, y los grandes fabricantes de procesadores de propósito general (CPU) ya están dando ese paso. Sin embargo hace ya tiempo que los fabricantes de procesadores gráficos lo han dado, y existe un lenguaje que permite programar procesadores masivamente paralelos sin necesidad de exponer explícitamente al programador su naturaleza paralela. Por tanto, es muy natural que cada vez más gente se preocupe de aprender a programar su GPU para resolver sus problemas de cálculo masivo, puesto que es más barata una GPU que 5 ordenadores o un multiprocesador equivalente. Arquitecturas como Cell, de IBM, empiezan a incorporar las características de las GPU en los procesadores principales, pero aun falta tiempo hasta que se consoliden, si es que finalmente lo hacen.