Multi-threading, la tecnología que explota las ventajas de máquinas dual-core y multiprocesadores

27 Nov 2005 en Servidores

El autor de este artículo publicado en DevX, medio que forma parte del mismo grupo editor que Datamation, es Alan Zeichick, analista principal de Camden Associates, firma independiente de investigación en tecnología que estudia los campos del desarrollo de software, el almacenamiento de datos y networking.
Ya sea que el código nativo esté escrito para Linux o Windows, que esté escrito en C++ o en otro lenguaje, es casi seguro que funcionará a mayor velocidad si permite que sus threads traten su funcionalidad en paralelos.
El sistema operativo dispone de mayor cantidad de alternativas para programar la ejecución de tareas en forma oportuna cuando se utilizan aplicaciones multi-threaded. Cuando se dispone de tecnología como Hyper-Threading de Intel sobre un desktop, notebook, en una workstation multiprocesador o en un servidor, la capacidad de ejecutar múltiples threads al mismo tiempo es importante en cuanto a performance y lo es aún más en un sistema usando procesadores dual-core.
Aunque el sistema tenga un solo procesador, la performance será mejor si el código está organizado para ejecutar threads. El paralelismo implícito y explícito diseñado dentro de la estructura binaria permite que el sistema operativo desmenuce sus propios threads y los de la aplicación y los acomode en tiempos para mantener a algunas tareas funcionando mientras otras están en estado de espera.
Como ocurre con toda nueva tecnología, los especialistas nos dicen que lo ideal sería iniciar desarrollos nuevos de aplicaciones desde cero, algo que en la vida real no es factible y por eso vamos a ocuparnos de las aplicaciones existentes, las que se usan para el funcionamiento de individuos y empresas en forma diaria.
Zeichick recomienda ejecutar cinco pasos para conseguir la mejora de las aplicaciones existentes con una estrategia de threading. Dependiendo de la aplicación, Zeichick nos dice que se obtendrán mejoras en la velocidad del orden de un 20 al 30 por ciento y a veces más. Esto será más notable cuando se opere con sistemas que utilizan tecnología Hyper Threading o similares, múltiples procesadores o procesadores multi-core.
Las etapas a seguir son las siguientes:
1- Analizar el código
2- Diseñar para el uso de threads
3- Codificar los threads
4- Proceder al debugging
5- Hacer el tuning para optimizar performance

Estos cinco pasos, según lo aclara Zeichick, son para refaccionar aplicaciones y bien vale la pena realizarlos sin olvidar que se está trabajando con código existente que ya funciona correctamente y que se está mejorando.
Por eso no hay que ni siquiera pensar en cambios de funcionalidad que vayan más allá de una mejor capacidad de respuesta y performance general, que son los cambios que veremos ahora. Conviene utilizar un sistema de administración de código fuente con un sólido dispositivo de control de versiones o bien una copia ordenada de los archivos fuente por si aparecen problemas y hace falta volver atrás o restaurar el código original. Si bien no deberían preverse problemas, la precaución nunca es poca.
La diferencia entre procesos y threads
Tanto threading como procesos son métodos para paralelizar a una aplicación. Sin embargo, los procesos son unidades de ejecución independientes que contienen su propia información, utilizan sus propios espacios y direcciones y sólo interactúan entre sí por medio de mecanismo de comunicación interproceso (generalmente administrados por el sistema operativo). Lo típico es que las aplicaciones se dividan en procesos durante su fase de diseño y los procesos maestros explícitamente abren sub-procesos cuando resulta lógico separar una parte significativa de la funcionalidad de una aplicación. Los procesos, en síntesis, son una construcción arquitectónica.
Por lo contrario, los threads son construcciones a nivel de código que no afectan a la arquitectura de una aplicación. Un proceso puede contener múltiples threads y todos los threads dentro de un proceso comparten el mismo estado y espacio de memoria y se pueden comunicar entre sí en forma directa porque comparten las mismas variables.
Los threads se generan para un beneficio inmediato que se visualiza como tarea secuencial, pero que no tiene que ser ejecutado en forma lineal (tal como una computación matemática compleja utilizando paralelismo o inicializando una matriz muy grande) y que, una vez cumplida su función, son absorbidos. El alcance de un thread está dentro de un módulo de código específico y eso nos permite centrarnos en threading sin afectar a la aplicación en general.

Primer paso: Analizar el Código
Cuando se retroalimenta a una aplicación para el uso de threads, es mejor tomar lo que esté más a mano. No hay que pensar en agregar threads en todas partes, ya que el esfuerzo será enorme y los resultados pueden no ser proporcionales. Lo que conviene es analizar el rendimiento a nivel del runtime actual para detectar qué partes de la aplicación se beneficiarán más con paralelismo y enfocarse en esas partes. Es posible encontrar situaciones donde un diez por ciento de los módulos se beneficiarán con la utilización de threading, aunque al agregar paralelismo a esos módulos, se puede duplicar la velocidad de ejecución de la aplicación completa.
La semana pasada les presentamos las herramientas que Intel, entre otros, tiene para analizar la performance del runtime. (Intel VTune Performance Analyzer, disponible para Linux y Windows).
Con un muestreo de runtime usando VTune, se pueden detectar los loops y funciones que consumen más CPU. Esos serán los objetivos. A continuación, aplique otros módulos de VTune para confirmar los resultados y disponer de un árbol para usar como mapa e impulsar threading hacia los algoritmos adecuados. Es aquí donde hay que analizar bien dónde threading puede impactar mejor a la performance, tanto para aprovechar al hardware multi-core o multiprocesador en cuanto a velocidad para la aplicación, como para que el scheduler del sistema operativo equilibre el mejor funcionamiento del sistema completo.

Segundo paso: Diseño para la incorporación de threads
Una vez identificadas las áreas ideales, que pueden ser elementos llamados por la aplicación con frecuencia y que consumen mucho tiempo de CPU, cabe evaluar si threading es aplicable. La pieza de código elegida tendrá tareas independientes y bien definidas con mínimas dependencias con otras tareas. También deberían ser tareas que involucran mucha ejecución y en las que vale la pena trabajar introduciendo threading. Son tareas ideales las operaciones de matrices, las tareas de procesamiento de señales digitales, la validación de datos y otras que se pueda imaginar operando en paralelo.
Existen algunos trucos para verificar si una iteración es realmente independiente. Por ejemplo, en un loop uno puede preguntarse: ¿Funcionará correctamente la aplicación si retrocedo el contador de loop? Para ser un buen candidato a threading, un loop también tiene que ser determinista, lo que equivale a decir que la cantidad de iteraciones a través del loop deberían ser fijas (constantes) o al menos completamente definidas al ingresar al loop.
Hay que evitar restricciones y conflictos. El acceso concurrente a variables compartidas y direcciones de almacenamiento no son un problema si se está iterando en un loop serialmente, pero puede tener efectos indeseables si se paraleliza. Cuando se utiliza threading, no se puede asumir ninguna secuencia de ejecución para los diferentes threads.
Tomemos un ejemplo de algo que no va a funcionar: Un loop busca a través de un archivo de datos una determinada entrada para luego salir en el momento en que la encuentra. Este caso no sería bueno para threading por dos razones. La primera es que el acceso al archivo es posiblemente serial y entonces no puede funcionar bien si el loop corre hacia atrás. La segunda razón es que al no ser predeterminada la cantidad de iteraciones, no se puede generar una cantidad de threads segura, ni dividir al archivo completo entre esos threads para que corran hasta completar la tarea.

Tercer paso: Codificar los threads
Una vez definidas qué partes del código es rentable cambiar, existen diferentes formas de modificar el código para usar threads. Algunas son algo más complicadas, pero tienen el mayor retorno de la inversión, tal como usar llamados a una API (Application Programming Interface) para crear, controlar y terminar threads. Si se quiere facilitar el trabajo, se puede utilizar OpenMP, que es considerablemente más fácil de usar que las llamadas manuales a las APIs.
OpenMP es un estándar para la inserción dentro del código fuente de indicadores que le dicen al compilador que cree código cuando la aplicación es creada. Con OpenMP es más fácil agregar estos pragmas al código. En general, no hará falta más que insertar esos pragmas en el código y se puede activar o desactivar a OpenMP si se utilizan los switches de compilador adecuados. Por ejemplo, si se quiere generar código paralelizado con el compilador Intel C/C++ 9.0 o Intel Fortran Compiler 9.0 for Windows, se utiliza el switch /Qopenmp. Si se está usando Linux, se utiliza el switch
–openmp con ambos compiladores. Si no se quiere paralelizar, se deja inactivo al switch y los pragmas son ignorados.
Se puede ir agregando más paralelismo progresivamente con OpenMP, ya que no es algo arquitectónico. Así, se sigue haciendo tuning y retoques según sea necesario. En DevX está disponible el artículo “OpenMP and Auto-Parallelization: Tools for Dual-Core and Multi-Core (http://www.devx.com/Intel/Article/28617)
Paso 4: Debugging y corrección
Una vez completados los cambios a una aplicación, cabe realizar un test de todos los módulos modificados. El test tiene que ser bien a fondo, usando las prácticas de aseguramiento de calidad (QA) disponibles en la empresa y también es adecuado hacer pruebas de regresión.
Pero eso no es suficiente. Dada la forma en que funciona threading y que se tomó código diseñado para funcionar serialmente y se lo modificó para que corra en paralelo, es necesario realizar otros tests para garantizar la ausencia de bugs específicos de un thread. Es necesario verificar dependencias. Por ejemplo, si el thread A tiene que ejecutarse antes que el B para que B lea lo que A escribió en una determinada ubicación de la memoria y eso no ocurre así porque A se demoró ¿Qué ocurre? Es necesario verificar que si los threads se procesan fuera de secuencia, no se afecte al resultado correcto.
Otro problema en cuanto a dependencias es el de situaciones de punto muerto, donde A espera que B ejecute algo y B espera que A haga lo propio, quedando así los dos paralizados.
Estas dos situaciones anteriores, conocidas en la jerga de programación como data-race y deadlock, respectivamente, no son fáciles de detectar cuando se trata de aplicaciones complejas. Aquí conviene usar herramientas como Intel Threading Tools, con Thread Checker que identifica los errores y conflictos de variables del threading y con Thread Profiler, que hace un seguimiento de los caminos críticos de la aplicación pasando de un thread al otro e identificando errores de sincronización y desbalanceos de carga entre threads.
Quinto paso: el tuning para una buena performance
Una vez que se analizó el código, se determinó dónde agregar threading basado en OpenMP, se insertó el código apropiado y se aseguró que funcione bien, lo que falta hacer es el tuning. Los threads pueden ser chequeados para obtener un máximo de eficiencia usando Intel Thread Profiler, producto que nos dirá si se está logrando la máxima eficiencia de un sistema multiprocesador o dual-core. El producto detecta si un thread se queda en un estado temporario de wait innecesariamente porque tareas paralelas se tienen que ejecutar antes que otras. Los threads inactivos representan una sobrecarga y desperdicio de ciclos de CPU. Por eso es necesario equilibrar la carga de trabajo entre threads.
Finalmente, Zeichick recomienda la lectura de “Programming with Hyper-Threading Technology” de Richard Gerber y Andrew Binstock.