es parte de una serie sobre IA distribuida en múltiples GPU:
Parte 1: Comprensión del paradigma de host y dispositivo (este artículo) Parte 2: Operaciones colectivas y punto a punto (próximamente) Parte 3: Cómo se comunican las GPU (próximamente) Parte 4: Acumulación de gradiente y paralelismo de datos distribuidos (DDP) (próximamente) Parte 5: ZeRO (próximamente) Parte 6: Paralelismo tensorial (próximamente)
Introducción
Esta guía explica los conceptos fundamentales de cómo funcionan juntas una CPU y una tarjeta gráfica discreta (GPU). Es una introducción de alto nivel diseñada para ayudarle a construir un modelo mental del paradigma del dispositivo host. Nos centraremos específicamente en las GPU NVIDIA, que son las más utilizadas para cargas de trabajo de IA.
Para las GPU integradas, como las que se encuentran en los chips Apple Silicon, la arquitectura es ligeramente diferente y no se tratará en esta publicación.
El panorama general: el host y el dispositivo
El concepto más importante a comprender es la relación entre el Host y el Dispositivo.
El anfitrión: esta es tu CPU. Ejecuta el sistema operativo y ejecuta su script Python línea por línea. El ejército es el comandante; está a cargo de la lógica general y le dice al Dispositivo qué hacer. El dispositivo: esta es tu GPU. Es un coprocesador potente pero especializado diseñado para cálculos masivos en paralelo. El Dispositivo es el acelerador; no hace nada hasta que el anfitrión le asigna una tarea.
Su programa siempre comienza en la CPU. Cuando desea que la GPU realice una tarea, como multiplicar dos matrices grandes, la CPU envía las instrucciones y los datos a la GPU.
La interacción CPU-GPU
El Anfitrión habla con el Dispositivo a través de un sistema de colas.
La CPU inicia comandos: su script, que se ejecuta en la CPU, encuentra una línea de código destinada a la GPU (por ejemplo, tensor.to(‘cuda’)). Los comandos están en cola: la CPU no espera. Simplemente coloca este comando en una lista especial de tareas pendientes para la GPU llamada CUDA Stream; más sobre esto en la siguiente sección. Ejecución asincrónica: la CPU no espera a que la GPU complete la operación real, el host pasa a la siguiente línea de su secuencia de comandos. Esto se denomina ejecución asincrónica y es clave para lograr un alto rendimiento. Mientras la GPU está ocupada procesando números, la CPU puede trabajar en otras tareas, como preparar el siguiente lote de datos.
Corrientes CUDA
Un CUDA Stream es una cola ordenada de operaciones de GPU. Las operaciones enviadas a una única secuencia se ejecutan en orden, una tras otra. Sin embargo, las operaciones en diferentes flujos se pueden ejecutar simultáneamente: la GPU puede hacer malabarismos con múltiples cargas de trabajo independientes al mismo tiempo.
De forma predeterminada, cada operación de GPU de PyTorch se pone en cola en la secuencia activa actual (generalmente es la secuencia predeterminada que se crea automáticamente). Esto es simple y predecible: cada operación espera a que finalice la anterior antes de comenzar. Para la mayoría del código, nunca te das cuenta de esto. Pero deja el rendimiento sobre la mesa cuando hay trabajo que podría superponerse.
Múltiples transmisiones: simultaneidad
El caso de uso clásico para múltiples flujos es la superposición de cálculos con transferencias de datos. Mientras la GPU procesa el lote N, puede copiar simultáneamente el lote N+1 de la RAM de la CPU a la VRAM de la GPU:
Corriente 0 (cálculo): [process batch 0]────[process batch 1]─── Transmisión 1 (datos): ────[copy batch 1]────[copy batch 2]───
Esta canalización es posible porque la transferencia de datos y computación ocurre en unidades de hardware separadas dentro de la GPU, lo que permite un verdadero paralelismo. En PyTorch, crea transmisiones y programa el trabajo en ellas con administradores de contexto:
Compute_stream = torch.cuda.Stream() transfer_stream = torch.cuda.Stream() with torch.cuda.stream(transfer_stream): # Poner en cola la transferencia en transfer_stream next_batch = next_batch_cpu.to(‘cuda’, non_blocking=True) with torch.cuda.stream(compute_stream): # Esto se ejecuta simultáneamente con la transferencia anterior salida = model(current_batch)
Tenga en cuenta el indicador non_blocking=True en .to(). Sin él, la transferencia aún bloquearía el subproceso de la CPU incluso cuando pretenda que se ejecute de forma asincrónica.
Sincronización entre transmisiones
Dado que las transmisiones son independientes, es necesario señalar explícitamente cuando una depende de otra. La herramienta contundente es:
torch.cuda.synchronize() # espera a que finalicen TODAS las transmisiones en el dispositivo
Un enfoque más quirúrgico utiliza CUDA Events. Un evento marca un punto específico en una secuencia y otra secuencia puede esperar sin detener el subproceso de la CPU:
event = torch.cuda.Event() con torch.cuda.stream(transfer_stream): next_batch = next_batch_cpu.to(‘cuda’, non_blocking=True) event.record() # marca: la transferencia se realiza con torch.cuda.stream(compute_stream): compute_stream.wait_event(event) # no comenzar hasta que se complete la transferencia salida = model(next_batch)
Esto es más eficiente que stream.synchronize() porque solo detiene la transmisión dependiente en el lado de la GPU: el subproceso de la CPU permanece libre para seguir haciendo cola.
Para el código de entrenamiento diario de PyTorch, no necesitará administrar las transmisiones manualmente. Pero características como DataLoader(pin_memory=True) y la captación previa dependen en gran medida de este mecanismo oculto. Comprender las transmisiones le ayuda a reconocer por qué existen esas configuraciones y le brinda las herramientas para diagnosticar cuellos de botella sutiles en el rendimiento cuando aparecen.
Tensores de PyTorch
PyTorch es un marco poderoso que abstrae muchos detalles, pero esta abstracción a veces puede oscurecer lo que sucede bajo el capó.
Cuando creas un tensor de PyTorch, tiene dos partes: metadatos (como su forma y tipo de datos) y los datos numéricos reales. Entonces, cuando ejecuta algo como esto t = torch.randn(100, 100, dispositivo=dispositivo), los metadatos del tensor se almacenan en la RAM del host, mientras que sus datos se almacenan en la VRAM de la GPU.
Esta distinción es importante. Cuando ejecuta print(t.shape), la CPU puede acceder inmediatamente a esta información porque los metadatos ya están en su propia RAM. Pero, ¿qué sucede si ejecuta print
Sincronización del dispositivo host
Acceder a los datos de la GPU desde la CPU puede desencadenar una sincronización del dispositivo host, un cuello de botella común en el rendimiento. Esto ocurre siempre que la CPU necesita un resultado de la GPU que aún no está disponible en la RAM de la CPU.
Por ejemplo, considere la línea print(gpu_tensor) que imprime un tensor que la GPU aún está calculando. La CPU no puede imprimir los valores del tensor hasta que la GPU haya terminado todos los cálculos para obtener el resultado final. Cuando el script llega a esta línea, la CPU se ve obligada a bloquearse, es decir, se detiene y espera a que finalice la GPU. Sólo después de que la GPU complete su trabajo y copie los datos de su VRAM a la RAM de la CPU, la CPU podrá continuar.
Como otro ejemplo, ¿cuál es la diferencia entre torch.randn(100, 100).to(dispositivo) y torch.randn(100, 100, dispositivo=dispositivo)? El primer método es menos eficiente porque crea los datos en la CPU y luego los transfiere a la GPU. El segundo método es más eficiente porque crea el tensor directamente en la GPU; la CPU solo envía el comando de creación.
Estos puntos de sincronización pueden afectar gravemente al rendimiento. La programación eficaz de la GPU implica minimizarlos para garantizar que tanto el host como el dispositivo permanezcan lo más ocupados posible. Después de todo, quieres que tus GPU funcionen brrrrr.
Ampliación: computación distribuida y rangos
El entrenamiento de modelos grandes, como los modelos de lenguajes grandes (LLM), a menudo requiere más potencia informática de la que puede ofrecer una sola GPU. La coordinación del trabajo entre varias GPU le lleva al mundo de la informática distribuida.
En este contexto, surge un nuevo e importante concepto: el Rank.
Cada rango es un proceso de CPU al que se le asigna un único dispositivo (GPU) y una identificación única. Si inicia un script de entrenamiento en dos GPU, creará dos procesos: uno con rango=0 y otro con rango=1.
Esto significa que está iniciando dos instancias separadas de su secuencia de comandos Python. En una sola máquina con múltiples GPU (un solo nodo), estos procesos se ejecutan en la misma CPU pero permanecen independientes, sin compartir memoria ni estado. El rango 0 controla su GPU asignada (cuda:0), mientras que el rango 1 controla otra GPU (cuda:1). Aunque ambos rangos ejecutan el mismo código, puede aprovechar una variable que contiene el ID del rango para asignar diferentes tareas a cada GPU, como hacer que cada una procese una porción diferente de los datos (veremos ejemplos de esto en la próxima publicación de blog de esta serie).
Conclusión
¡Felicitaciones por leer hasta el final! En esta publicación, aprendiste sobre:
La relación Host/Dispositivo Ejecución asincrónica CUDA Streams y cómo permiten el trabajo simultáneo de GPU Sincronización host-dispositivo
En la próxima publicación del blog, profundizaremos en las operaciones colectivas y punto a punto, que permiten que múltiples GPU coordinen flujos de trabajo complejos, como el entrenamiento de redes neuronales distribuidas.