La nueva generación de GPUs de Nvidia, Volta, por fin implementa SIMT

Desde que Nvidia creó CUDA siempre ha hablado de que sus procesadores gráficos son SIMT (Single Instruction Multiple Thread). Pero dicho concepto estaba muy lejos de la realidad, porque hasta Volta lo que implementaban era en realidad SIMD con predicación.

En el caso de CUDA los Warps eran el Thread. Y cada elemento de dicho Thread (Warp) se etiquetaba con un predicado que indicaba si una determinada instrucción iba a ser aplicada a dicho miembro del Thread. Una GPU de Nvidia es básicamente una máquina vectorial (Cray-1) que procesa vectores de 128 bytes (un Warp son 32 elementos de 4 bytes = 128 bytes). Por tanto eso significa que en un condicional un Thread ejecuta todo el código, incluido la rama que no se calcula, malgastando ciclos (ocupación de la GPU en términos de Nvidia). Un código con muchas bifurcaciones era por tanto bastante perjudicial para una GPU. En este link hay una explicación de los distintos tipos de paralelismo a nivel de hilo, aunque para ello emplean el término SIMT para describir el procesador vectorial que son las GPUs de Nvidia (diapositivas SIMD vs. Vectorial o SIMT como lo llama Nvidia).
if (a == b) {
    c = a*b + 10;
} else {
    c = a*b - 35;
}
En el código anterior, si todos los elementos de los vectores fueran iguales (
a[i] = b[i]
) todos los predicados estarían marcados como falso (no ejecutar) para el camino del «else». Algo que en un hilo de verdad tomaría hasta 4 ciclos (1 de comparación, 1 de comprobación, 1 de cálculo, 1 de salto) en las GPUs de Nvidia tomaba hasta 5 ciclos (+1 de cálculo del else). Con bifurcaciones cuyo cuerpo contenga múltiples instrucciones la diferencia es aún más notable.

En el GTC 2017 se presentó Volta que incorpora lo necesario para denominar Thread (hilo) a cada elemento de un Warp: el contador de programa. Ahora cada elemento del Warp tiene su propia dirección de memoria de instrucciones, pudiendo ejecutar código de manera independiente en vez de avanzar todo el Warp de manera síncrona (lockstep) por el código como si fuera una apisonadora llevándose por delante todo lo que haya. En el blog de Nvidia explican las novedades de Volta más detalladamente, incluido lo del contador de programa (program counter) para cada uno de los elementos del vector.

Esto permitirá aumentar la eficiencia en el uso de la GPU ya que hay más granularidad a la hora de poner cosas en ejecución. Antes se planificaba a nivel de vector y ahora el procesador puede planificarse a nivel de elemento del vector como si fueran hilos de un procesador escalar, aunque sin serlo. El compilador se encarga de generar el código con la distribución adecuada de datos para el procesador.

¿Y cuál es pues la diferencia con SIMD? Con las instrucciones SIMD en un procesador superescalar los datos han de estar consecutivos en memoria mientras que con un procesador vectorial no es necesario, aunque si deseas máximo rendimiento deben estarlo para no tener conflictos en los bancos de memoria. En las últimas generaciones de procesadores Intel se implementaron instrucciones de scatter y gather que permiten leer y escribir en direcciones no consecutivas de memoria por lo que esa diferencia también ha desaparecido. Adicionalmente Intel ha implementado también los predicados para las instrucciones con la última versión de AVX: AVX512. Con esa base Intel desarrolló los aceleradores Xeon Phi, compitiendo con las GPUs de Nvidia en el mercado de HPC, big data, cloud computing, etc.

Con el cambio en Volta cada línea SIMD tiene su propio contador de programa así que para emular un funcionamiento similar con un procesador de Intel o AMD habría que tener tantos hilos como hilos en CUDA y utilizar un subconjunto de los mismos para el código SIMD. Un modelo de programación que lo pone fácil es OpenMP y su #pragma simd. Con un número de hilos habitual (1 por núcleo p.ej.) permite indicar regiones o funciones para ejecutar en modo SIMD, asignando los bloques de datos a procesar de forma vectorial a cada hilo según sea necesario. Como en el caso de CUDA, el compilador se encarga de generar el código adecuado, con la distribución adecuada de iteraciones según el patrón de acceso a los datos.

Comentarios