El problema fundamental que aborda este artículo es la optimización del rendimiento en sistemas de computación heterogénea, donde un programa requiere tanto la eficiencia de la ejecución secuencial en CPU para ciertas tareas (como algoritmos NP-hard aproximados) como la capacidad de procesamiento masivo en paralelo de las GPU para otras (como cálculos numéricos intensivos). La tesis es que un modelo de compilación que asume una única región de memoria (ej. toda en GPU) para todo el programa, aunque simplifica la implementación del compilador, introduce cuellos de botella severos debido a las costosas transferencias de datos entre CPU y GPU cuando las operaciones secuenciales acceden a datos en la memoria de la GPU.

Este desafío no es nuevo; ha existido desde la popularización de las arquitecturas de GPU de propósito general (GPGPU) y la necesidad de orquestar flujos de trabajo complejos que no son puramente paralelos. La dificultad radica en cómo un compilador de alto nivel puede inferir o ser instruido para gestionar de forma óptima la ubicación de los datos y la ejecución del código en el hardware adecuado, minimizando la latencia de comunicación y maximizando el throughput efectivo. El contexto actual de Futhark, un lenguaje funcional para computación paralela, resalta cómo incluso lenguajes diseñados para la paralelización deben lidiar con las realidades de las arquitecturas heterogéneas y la necesidad de flexibilidad en la gestión de recursos.

Arquitectura del Sistema

El sistema en cuestión es el compilador de Futhark, que traduce programas funcionales a código ejecutable para diferentes backends (CPU secuencial, CPU multi-core, GPU). La arquitectura original de Futhark, para los backends de GPU, asume que todos los arrays residen en la memoria de la GPU. Esto simplifica el diseño del compilador, ya que el código de la CPU puede acceder a la memoria de la GPU (aunque con un alto costo de API), pero el código de la GPU no puede acceder directamente a la memoria de la CPU. Esta decisión de diseño, aunque segura, se convierte en un cuello de botella de rendimiento cuando el código secuencial de la CPU necesita acceder a elementos individuales de arrays en la GPU, resultando en transferencias de datos elemento a elemento con una sobrecarga ruinosa.

La solución implementada es un 'hack' pragmático: la introducción del atributo #[cpu_function]. Cuando se aplica a una función no inlined, este atributo fuerza al compilador a generar código para la CPU para el cuerpo de la función y a almacenar todos los arrays de entrada, intermedios y de salida en la memoria de la CPU. El compilador inserta automáticamente el código necesario para transferir arrays entre la memoria de la CPU y la GPU en los puntos de llamada. Inicialmente, la función devolvía resultados en memoria de CPU, pero esto causaba problemas cuando esos resultados eran consumidos por kernels de GPU. La implementación final ajusta #[cpu_function] para que los resultados de la función se copien a la memoria de la GPU al final de su ejecución, permitiendo que el resto del compilador asuma que los arrays están en la GPU. Esto implica una copia de datos en bloque, que es más eficiente que las copias elemento a elemento. Este enfoque, aunque no es una solución de inferencia automática, permite al programador guiar al compilador para optimizar la ubicación de los datos y la ejecución para cargas de trabajo híbridas.

Flujo de Ejecución Híbrida con #[cpu_function]

  1. 1 Inicio Programa Futhark El programa principal comienza su ejecución, asumiendo arrays en GPU.
  2. 2 Llamada a #[cpu_function] El compilador detecta una llamada a una función anotada con #[cpu_function].
  3. 3 Copia de Entradas a CPU Los arrays de entrada necesarios para la función se copian de la memoria de l...
  4. 4 Ejecución en CPU El cuerpo de la función se ejecuta en la CPU, utilizando memoria de la CPU pa...
  5. 5 Copia de Salida a GPU El array de resultado de la función se copia de la memoria de la CPU a la mem...
  6. 6 Continuación en GPU El programa principal continúa su ejecución, asumiendo arrays en GPU.
CapaTecnologíaJustificación
compute Futhark Compiler Orquesta la compilación y ejecución de código en CPU y GPU, gestionando la asignación de memoria y las transferencias de datos. vs Inferencia automática de ubicación de datos y cómputo, Soporte de 'separate compilation' con diferentes backends
compute GPU Backend (Futhark) Genera código para la ejecución paralela en GPU, asumiendo por defecto que todos los arrays residen en la memoria de la GPU.
compute C Backend (Futhark) Genera código C secuencial para la CPU, utilizado para funciones marcadas con #[cpu_function] o cuando se requiere ejecución puramente secuencial.
compute Multicore Backend (Futhark) Genera código paralelo para la CPU, ofreciendo un compromiso entre la ejecución secuencial y la GPU.

Trade-offs

Ganancias
  • ▲▲ Rendimiento de algoritmos secuenciales en CPU
  • ▲ Flexibilidad en la orquestación de cargas de trabajo híbridas
  • ▲▲ Reducción de la sobrecarga de comunicación por elemento
Costes
  • △ Complejidad del compilador y del modelo de programación
  • △ Costo de copias de datos en bloque entre CPU y GPU
  • △ Necesidad de anotaciones explícitas del programador
#[noinline] #[cpu_function]def seqsum [n] (xs: [n]i32) =
  loop sum = 0 for i < n do
    sum + xs[i]
Ejemplo de cómo se usa el atributo `#[cpu_function]` para forzar la compilación y ejecución de una función en la CPU, junto con la gestión de memoria asociada.

Fundamentos Teóricos

El problema de la asignación óptima de recursos y la gestión de memoria en sistemas heterogéneos se relaciona con principios fundamentales de la computación paralela y distribuida. La idea de mover datos y cómputo entre diferentes tipos de procesadores y jerarquías de memoria es un tema recurrente en la investigación de compiladores y sistemas operativos. Conceptos como la 'locality of reference' y la 'data movement cost' son centrales aquí. La penalización por transferencias de datos elemento a elemento entre CPU y GPU es un ejemplo clásico de cómo la latencia de comunicación puede dominar el tiempo de ejecución, un problema bien documentado en la literatura de computación de alto rendimiento (HPC).

Aunque no se cita un paper específico, el desafío de la 'coherencia de caché' y la 'gestión de memoria unificada' en arquitecturas heterogéneas ha sido objeto de numerosos estudios. Por ejemplo, trabajos sobre 'Unified Memory' en CUDA de NVIDIA (introducido alrededor de 2014) buscan simplificar la programación al permitir que la CPU y la GPU compartan un espacio de direcciones virtual, con el sistema operativo gestionando las migraciones de páginas de forma transparente. Sin embargo, incluso con memoria unificada, el rendimiento óptimo a menudo requiere una comprensión explícita de los patrones de acceso a datos para minimizar las migraciones costosas. El enfoque de Futhark, al permitir una anotación explícita (#[cpu_function]), se alinea con la necesidad de que los programadores proporcionen hints al compilador cuando la inferencia automática es demasiado compleja o ineficiente, un patrón visto en optimizaciones de compiladores desde hace décadas.