a, se ejecuta un modelo de aprendizaje profundo en un acelerador de GPU dedicado utilizando lotes de datos de entrada que recibe de un host de CPU. Idealmente, la GPU (el recurso más caro) debería utilizarse al máximo, con períodos mínimos de tiempo de inactividad. En particular, esto significa que cada vez que complete su ejecución en un lote, el lote siguiente estará "maduro y listo" para su procesamiento. Cuando esto no sucede, la GPU se inactiva mientras espera datos de entrada, un cuello de botella de rendimiento común al que a menudo se hace referencia como inanición de GPU.
En publicaciones anteriores (por ejemplo, consulte Una estrategia de almacenamiento en caché para identificar cuellos de botella en la canalización de entrada de datos), analizamos las causas comunes de este problema, que incluyen: recuperación de almacenamiento ineficiente, agotamiento de recursos de CPU y cuellos de botella en la transferencia de host a dispositivo. En esta publicación, nos centramos en los cuellos de botella en la transferencia de datos y revisamos su identificación y resolución, esta vez con la ayuda de NVIDIA Nsight™ Systems (nsys), un generador de perfiles de rendimiento diseñado para analizar la actividad de todo el sistema de las cargas de trabajo que se ejecutan en las GPU NVIDIA.
NVIDIA Nsight frente a PyTorch Profiler
Los lectores familiarizados con nuestro trabajo pueden sorprenderse al mencionar el generador de perfiles NVIDIA Nsight en lugar de PyTorch Profiler. En nuestras publicaciones anteriores, hemos abogado firmemente por el uso de PyTorch Profiler en el desarrollo de modelos de IA/ML como herramienta para identificar y optimizar el rendimiento del tiempo de ejecución. Una y otra vez, hemos demostrado su aplicación a una amplia variedad de problemas de rendimiento. Su uso no requiere ninguna instalación especial y puede ejecutarse sin permisos especiales del sistema operativo. NVIDIA Nsight Profiler, por otro lado, requiere una configuración de sistema dedicada (o un contenedor NVIDIA dedicado) y, para algunas de sus funciones, permisos elevados, lo que hace que su uso sea menos accesible y más complicado que PyTorch Profiler.
Los dos generadores de perfiles difieren en su enfoque: PyTorch Profiler es un generador de perfiles de marco estrechamente acoplado con PyTorch y muy centrado en cómo los modelos utilizan la pila de software PyTorch y las bibliotecas de soporte. NVIDIA Nsight Profiler es un generador de perfiles a nivel de sistema; no conoce los detalles del modelo que se ejecuta ni qué marco se utiliza, sino más bien cómo se utilizan y utilizan los componentes de todo el sistema. Si bien PyTorch Profiler se destaca en el seguimiento de las operaciones de bajo nivel de la ejecución de un modelo PyTorch, nsys proporciona una vista detallada de las actividades de todo el sistema (hardware GPU, flujos CUDA, interrupciones del sistema operativo, red, PCIe, etc.). Para muchos problemas de rendimiento, el generador de perfiles PyTorch es suficiente para identificar y resolver el origen del cuello de botella; Pero algunas situaciones requieren nsys perfilador, los “peces gordos”, para obtener conocimientos más profundos sobre el funcionamiento interno del sistema subyacente.
En esta publicación pretendemos demostrar algunas de las capacidades únicas de nsys Profiler y su aplicación al cuello de botella común en la transferencia de datos.
Describir
Para facilitar nuestra discusión, definiremos una carga de trabajo de ML de juguete con un cuello de botella en el rendimiento de la transferencia de datos y procederemos a introducir una serie de optimizaciones sucesivas en un intento de resolverlo. A lo largo del proceso, utilizaremos el perfilador nsys para analizar el rendimiento del sistema y evaluar el impacto de las modificaciones del código.
Configuración
Ejecutaremos nuestros experimentos en una instancia Amazon EC2 g6e.2xlarge con una GPU NVIDIA L40S que ejecuta una AMI de AWS Deep Learning (Ubuntu 24.04) con PyTorch (2.8). Para instalar el perfilador nsys-cli (versión 2025.6.1) seguimos las pautas oficiales de NVIDIA:
wget https://developer.nvidia.com/downloads/assets/tools/secure/nsight-systems/2025_6/NsightSystems-linux-cli-public-2025.6.1.190-3689520.deb sudo apt install ./NsightSystems-linux-cli-public-2025.6.1.190-3689520.deb
La biblioteca NVIDIA Tools Extension (NVTX) nos permite anotar nuestro código con etiquetas legibles por humanos para aumentar la legibilidad y comprensión del seguimiento del rendimiento. Si bien PyTorch ofrece soporte NVTX integrado a través de sus API torch.cuda.nvtx, usaremos el paquete nvtx independiente (versión 0.2.14) que admite la codificación por colores de la línea de tiempo de seguimiento para un mejor análisis visual:
instalación de pip nvtx
Descargos de responsabilidad
El código que compartiremos tiene fines demostrativos; no confíe en su corrección u optimidad. No interprete nuestro uso de ninguna biblioteca, herramienta o plataforma como un respaldo a su uso. El impacto de las optimizaciones que cubriremos puede variar mucho según los detalles del modelo y el entorno de ejecución. Asegúrese de evaluar su efecto en su propio caso de uso antes de integrar su uso.
Muchas gracias a Yitzhak Levi y Gilad Wasserman por sus contribuciones a esta publicación.
Un modelo de juguete PyTorch
Introducimos un script de entrenamiento diseñado intencionalmente para que consista en un cuello de botella en el proceso de entrada de datos.
En el bloque de código siguiente definimos un modelo de clasificación de imágenes simple con una columna vertebral ResNet-18.
tiempo de importación, antorcha, torchvision DISPOSITIVO = "cuda" modelo = torchvision.models.resnet18().to(DEVICE).train() optimizador = torch.optim.Adam(model.parameters())
A continuación, definimos un conjunto de datos sintéticos que usaremos para entrenar nuestro modelo de juguete.
from torch.utils.data import Dataset, DataLoader WARMUP_STEPS = 10 PROFILE_STEPS = 3 COOLDOWN_STEPS = 1 TOTAL_STEPS = WARMUP_STEPS + PROFILE_STEPS + COOLDOWN_STEPS BATCH_SIZE = 64 TOTAL_SAMPLES = TOTAL_STEPS * BATCH_SIZE IMG_SIZE = 512 # Un conjunto de datos sintético con imágenes aleatorias y etiquetas clase FakeDataset(Dataset): def __len__(self): return TOTAL_SAMPLES def __getitem__(self, index): img = torch.randn((3, IMG_SIZE, IMG_SIZE)) label = torch.tensor(index % 10) return img, label train_loader = DataLoader( FakeDataset(), batch_size=BATCH_SIZE )
Por último, definimos un paso de entrenamiento estándar programado para ejecutar nsys-profiler durante tres pasos usando los comandos torch.cuda.profiler.start y stop, destinados a usarse junto con nsys cli. Resaltamos los componentes del paso de capacitación utilizando la utilidad nvtx.annotate. Consulte la documentación oficial para obtener más detalles sobre la creación de perfiles con nsys en PyTorch.
importar nvtx desde torch.cuda importar perfilador def copy_data(batch): datos, objetivos = lote data_gpu = data.to(DEVICE) objetivos_gpu = objetivos.to(DEVICE) devolver data_gpu, objetivos_gpu def compute_step(modelo, lote, optimizador): datos, objetivos = lote salida = modelo(datos) pérdida = torch.nn.functional.cross_entropy(salida, objetivos) loss.backward() optimizador.step() optimizador.zero_grad() pérdida de retorno data_iter = iter(train_loader) para i en rango(TOTAL_STEPS): if i == WARMUP_STEPS: # iniciar nsys perfilador torch.cuda.synchronize() start_time = time.perf_counter() perfilador.start() elif i == WARMUP_STEPS + PROFILE_STEPS: # detener nsys perfilador torch.cuda.synchronize() perfiler.stop() end_time = time.perf_counter() con nvtx.annotate(f"Batch {i}", color="blue"): con nvtx.annotate("obtener lote", color="red"): lote = next(data_iter) con nvtx.annotate("copiar lote", color="amarillo"): lote = copiar_data(batch) con nvtx.annotate("Calcular", color="verde"): computar_paso(modelo, lote, optimizador) tiempo_total = tiempo_final – rendimiento del tiempo_inicio = PROFILE_STEPS / tiempo_total print(f"Rendimiento: {rendimiento:.2f} pasos/seg")
Ejecutamos nuestro script usando la opción cudaProfilerApi para iniciar y detener el generador de perfiles mediante programación. Consulte la documentación oficial para obtener detalles completos sobre la creación de perfiles desde nsys cli.
perfil nsys –capture-range=cudaProfilerApi –trace=cuda,nvtx,osrt –output=baseline python train.py
Esto da como resultado un archivo de seguimiento baseline.nsys-rep que copiamos a nuestra máquina de desarrollo para su análisis.
Para hacer una comparación con PyTorch Profiler, definimos un bucle de entrenamiento alternativo programado con PyTorch Profiler y anotado con la utilidad torch.profiler.record_function:
desde torch.profiler import (perfil, record_function, programación, tensorboard_trace_handler) con perfil (programación=programación(espera=0, calentamiento=WARMUP_STEPS, active=PROFILE_STEPS, repetición=1), on_trace_ready=tensorboard_trace_handler('./baseline'), record_shapes=True, with_stack=True) como prof: para i en rango(TOTAL_STEPS): con record_function("obtener lote"): lote = siguiente(data_iter) con record_function("copiar lote"): lote = copiar_datos(batch) con record_function("calcular"): calcular_paso(modelo, lote, optimizador) prof.step()
El rendimiento de nuestro experimento de referencia es de 2,97 pasos por segundo. En las siguientes secciones utilizaremos los seguimientos del perfil para identificar cuellos de botella en el rendimiento en nuestro paso de entrenamiento e intentaremos mejorar este resultado.
Análisis de desempeño de referencia
Para analizar el archivo de seguimiento nsys resultante, lo abrimos en la aplicación GUI de Nsight Systems. En la imagen a continuación ampliamos la línea de tiempo de dos de los pasos de entrenamiento capturados por el generador de perfiles:
El rastro contiene una gran cantidad de información, de la cual abordaremos solo un subconjunto en esta publicación. Consulte la documentación de nsys para conocer funcionalidades y características adicionales.
La línea de tiempo se divide en dos partes: la sección CUDA que informa la actividad de la GPU y la sección de subprocesos que informa la actividad de la CPU. La sección CUDA hace una clara distinción entre la actividad (cómputo) del núcleo de la GPU (90,9%) y la actividad de la memoria (9,1%). Las barras superiores de cada sección informan la utilización de cada uno de los recursos y ambas secciones incluyen una sección NVTX con las anotaciones en colores que incluimos en nuestro paso de capacitación. Tomamos nota de las siguientes observaciones:
La GPU está inactiva durante aproximadamente el 50 % de cada paso de entrenamiento. Esto se puede ver por la cantidad de tiempo que tarda cada lote (en azul) en la barra NVTX de la GPU y los grandes bloques de espacios en blanco entre ellos. La actividad de la GPU para cada lote comienza inmediatamente después de que se completa la actividad de "obtener lote" en la CPU. Comienza con la copia de la memoria del host al dispositivo, marcada en verde claro y continúa con los cálculos del kernel, marcados en azul claro. Una vez que la CPU ha iniciado la memoria de la GPU y los comandos de cálculo para el lote N, pasa al siguiente lote en el ciclo de entrenamiento, lo que genera una superposición parcial del lote N+1 en la CPU con el lote N en la GPU. La gran mayoría del subproceso de la CPU se gasta en la actividad de "obtener lotes". Este constituye el principal obstáculo en nuestro experimento de referencia.
El seguimiento del perfil apunta a un claro culpable: el cargador de datos. De forma predeterminada, PyTorch realiza la carga de datos de un solo proceso: se utiliza un solo proceso de CPU para cargar el siguiente lote de entrada de datos, copiarlo a la GPU e iniciar los núcleos de cómputo, todo de manera secuencial. Esto generalmente resulta en una grave subutilización de los recursos de la CPU al: 1) limitar la carga de datos a un solo proceso y 2) hacer que la carga del siguiente lote dependa de la finalización del procesamiento de la CPU (es decir, la carga del kernel) del lote anterior. Nuestro uso irresponsable de los recursos de nuestra CPU ha provocado que nuestra GPU se quede sin datos de entrada.
Se podría haber llegado a la misma conclusión utilizando el seguimiento de PyTorch Profiler que se muestra a continuación:
Aquí también podemos ver largos períodos de subutilización de la GPU causados por los largos bloques de "obtener lotes" en el lado de la CPU.
Optimización 1: carga de datos multiproceso
El primer paso es modificar la canalización de entrada de datos para utilizar la carga de datos multiproceso. Configuramos la cantidad de trabajadores para que coincida con las 8 vCPU disponibles en nuestra instancia Amazon EC2 g6e.2xlarge. En un escenario del mundo real, este valor debe ajustarse para obtener un rendimiento óptimo:
NUM_WORKERS = 8 train_loader = DataLoader( FakeDataset(), lote_size=BATCH_SIZE, num_workers=NUM_WORKERS )
Después de este cambio, nuestro rendimiento salta a 4,81 pasos por segundo, una mejora del 62 % con respecto a nuestro resultado inicial. El seguimiento correspondiente del perfilador nsys se muestra a continuación:
Tenga en cuenta que el segmento rojo "obtener lote" se ha convertido en solo una pequeña porción de cada paso en la barra NVTX. En su lugar, el bloque amarillo "copiar lote" ahora ocupa un lugar central. Como resultado de nuestro uso de carga de datos multiproceso, ahora siempre hay un nuevo lote listo para procesar, pero ¿podemos hacerlo mejor?
Si observamos más de cerca la sección de GPU, vemos que todavía hay una porción significativa (~290 milisegundos) de tiempo de inactividad entre la operación de la memoria y el cálculo del kernel. Este tiempo de inactividad está perfectamente alineado con una operación "munmap" en la barra de tiempo de ejecución del sistema operativo. El bloque "munmap" es una operación de limpieza de memoria del lado de la CPU que se realiza justo después de que se completa la copia de la memoria CUDA. Ocurre al final de la larga operación amarilla de “copiar lotes”. Los núcleos de cálculo se inician en la GPU solo después de que se haya completado la limpieza de la memoria. Este es un patrón claro de copia de memoria síncrona de host a dispositivo: la CPU no puede continuar con la carga del kernel hasta que la operación de copia de datos se haya completado por completo y la GPU permanece inactiva hasta que la CPU carga los kernels.
El seguimiento del perfilador de PyTorch muestra el mismo tiempo de inactividad de la GPU, pero no proporciona la misma sugerencia de "munmap". Este es nuestro primer ejemplo de la ventaja de la visibilidad de todo el sistema del perfilador nsys.
Una vez que hemos descubierto el cuello de botella en el rendimiento de la copia de datos, procedemos a nuestra siguiente optimización.
Optimización 2: transferencia de datos asincrónica
La solución al cuello de botella que hemos encontrado es programar nuestro paso de entrenamiento para cargar datos de forma asíncrona. Esto permite que la CPU inicie los núcleos de cálculo inmediatamente después de enviar el comando de copia de memoria, sin esperar a que se complete la copia de memoria. De esta manera, la GPU puede comenzar a procesar los núcleos tan pronto como se realiza la copia de la memoria CUDA. Habilitar la copia de datos asincrónica requiere dos cambios: primero debemos programar el cargador de datos para usar memoria fija (en lugar de memoria paginable) y segundo, debemos pasar el argumento non_blocking=True a las operaciones to():
NUM_WORKERS = 8 ASYNC_DATATRANSFER = True train_loader = DataLoader( FakeDataset(), lote_size=BATCH_SIZE, num_workers=NUM_WORKERS, pin_memory=ASYNC_DATATRANSFER ) def copy_data(batch): datos, objetivos = lote data_gpu = data.to(DEVICE, non_blocking=ASYNC_DATATRANSFER) objetivos_gpu = objetivos.to(DISPOSITIVO, sin_bloqueo=ASYNC_DATATRANSFER) devuelve datos_gpu, objetivos_gpu
El uso de la carga de datos asíncrona da como resultado un rendimiento de 5,91 pasos por segundo: una mejora adicional del 23 % y una mejora general del 99 %. El seguimiento del perfil resultante se muestra a continuación:
Ahora vemos todas las operaciones de la CPU agrupadas al comienzo del seguimiento. Hemos eliminado todos los obstáculos de rendimiento en el lado de la CPU, permitiéndole cargar libremente los datos y los núcleos en la GPU. En la sección de GPU, vemos actividad continua sin tiempo de inactividad. Sin embargo, sí vemos una clara separación entre las actividades de la memoria CUDA (en verde claro) y las actividades del kernel CUDA (en azul claro). El perfilador PyTorch, por el contrario, no deja clara esta distinción. Esta es otra ventaja del generador de perfiles centrado en hardware y, en el caso de nuestro experimento con juguetes, es lo que informa los siguientes pasos de nuestra optimización.
Optimización 3: canalización con flujos CUDA
Nuestras optimizaciones finales se derivan del hecho de que las GPU modernas, como la NVIDIA L40S, utilizan motores independientes para copiar memoria (DMA) y ejecutar núcleos de cómputo (SM). Podemos aprovechar esto paralelizando las distintas actividades de memoria y kernel que vimos en el seguimiento del perfilador nsys. Programaremos esto mediante el uso de transmisiones CUDA.
En una publicación anterior, ampliamos la oportunidad de optimizar las cargas de trabajo de AI/ML utilizando CUDA Streams. Aquí, aplicamos una estrategia de canalización similar: definimos dos secuencias CUDA de “copia” y “calculación” distintas y programamos la secuencia de “copia” para copiar el lote N+1 al mismo tiempo que la secuencia de “cómputo” procesa el lote N:
# define dos flujos CUDA Compute_stream = torch.cuda.Stream() copy_stream = torch.cuda.Stream() # extrae el primer lote next_batch = next(data_iter) con torch.cuda.stream(copy_stream): next_batch = copy_data(next_batch) para i in range(TOTAL_STEPS): if i == WARMUP_STEPS: torch.cuda.synchronize() start_time = time.perf_counter() perfiler.start() elif i == WARMUP_STEPS + PROFILE_STEPS: torch.cuda.synchronize() perfiler.stop() end_time = time.perf_counter() con nvtx.annotate(f"Batch {i}", color="blue"): # esperar a que el flujo de copia complete la copia del lote N compute_stream.wait_stream(copy_stream) lote = next_batch # ejecutar modelo en lote Prueba de flujo de cómputo N+1: con nvtx.annotate("obtener lote", color="red"): next_batch = next(data_iter) con torch.cuda.stream(copy_stream): con nvtx.annotate("copiar lote", color="amarillo"): next_batch = copy_data(next_batch) excepto: # alcanzó el final del conjunto de datos next_batch = Ninguno # ejecutar modelo en el lote N flujo de cómputo con torch.cuda.stream(compute_stream): con nvtx.annotate("Compute", color="green"): compute_step(modelo, lote, optimizador) total_time = end_time – start_time rendimiento = PROFILE_STEPS / total_time print(f"Rendimiento: {rendimiento:.2f} pasos/seg")
Esta optimización da como resultado un rendimiento de 6,44 pasos por segundo, una mejora del 9 % con respecto a nuestro experimento anterior. Observamos que el impacto de esta optimización está limitado por la duración del tipo de operación más largo de los dos. En nuestro seguimiento de perfil anterior, el bloque de memoria tardó 15,5 milisegundos y el bloque del núcleo tardó 155 milisegundos. En el seguimiento del perfil actual, los pasos completos de la GPU tardan 155 milisegundos, lo que significa que el tiempo de copia de la memoria se completa oculto por el tiempo de cálculo del kernel y que nuestra optimización alcanza el máximo resultado posible.
El uso de flujos CUDA y su impacto en la utilización de GPU se puede ver en los rastros de ambos perfiladores:
Optimización 4: captación previa a CUDA
Para nuestro paso final, trasladamos la copia de datos del proceso del bucle de entrenamiento principal al proceso de carga de datos: en lugar de llamar explícitamente a la función de copia dentro del bucle de entrenamiento, asumimos que los lotes devueltos por el iterador de datos ya están colocados en la GPU.
En el bloque de código siguiente, envolvemos nuestro cargador de datos con una clase iteradora de captación previa de CUDA. Tenga en cuenta que esta es una implementación simplificada destinada a fines de demostración. Es posible que se requiera más trabajo para escenarios más complejos (por ejemplo, capacitación DDP). Alternativamente, puedes considerar una implementación de terceros como torchtnt.utils.data.data_prefetcher.CudaDataPrefetcher:
clase DataPrefetcher: def __init__(self, loader): self.loader = iter(loader) self.stream = torch.cuda.Stream() self.next_batch = Ninguno self.preload() def preload(self): prueba: datos, objetivos = next(self.loader) con torch.cuda.stream(self.stream): con nvtx.annotate("copiar lote", color="amarillo"): next_data = data.to(DEVICE, non_blocking=True) next_targets = objetivos.to(DEVICE, non_blocking=True) self.next_batch = (next_data, next_targets) excepto: self.next_batch = (Ninguno, Ninguno) def __iter__(self): devolver self def __next__(self): torch.cuda.current_stream().wait_stream(self.stream) datos, objetivos = self.next_batch self.preload() devuelve datos, objetivos data_iter = DataPrefetcher(train_loader) para i en el rango(TOTAL_STEPS): if i == WARMUP_STEPS: torch.cuda.synchronize() start_time = time.perf_counter() perfiler.start() elif i == WARMUP_STEPS + PROFILE_STEPS: torch.cuda.synchronize() perfiler.stop() end_time = time.perf_counter() con nvtx.annotate(f"Batch {i}", color="blue"): con nvtx.annotate("get batch", color="red"): lote = next(data_iter) con nvtx.annotate("Compute", color="green"): pérdida = Compute_step(modelo, lote, optimizador) total_time = end_time – start_time rendimiento = PROFILE_STEPS / total_time print(f"Rendimiento: {rendimiento:.2f} pasos/seg")
Esta optimización da como resultado un rendimiento de 6,44 pasos por segundo, el mismo que en nuestro experimento anterior. Esto no debería sorprendernos, ya que ya hemos visto que el rendimiento está limitado por el cómputo de la GPU de 155 milisegundos y nuestra optimización no ha hecho nada para reducir el tiempo de cómputo del kernel.
De manera más general, a pesar de la eliminación de la llamada de copia del bucle principal, es posible que le resulte difícil encontrar una situación en la que esto tenga un impacto significativo en el rendimiento, ya que la llamada ya se llama de forma asincrónica. Sin embargo, dados los cambios mínimos en el ciclo de entrenamiento, es posible que esta solución sea más limpia y/o más aplicable para su uso con bibliotecas de alto nivel que no permiten un control detallado del ciclo de entrenamiento.
Como era de esperar, los perfiles de este experimento parecen casi idénticos a los anteriores. La principal diferencia es la ubicación del bloque amarillo de "copiar datos" en la fila NVTX de la sección de la CPU.
Resultados
La siguiente tabla resume los resultados de nuestros experimentos:
Las optimizaciones, impulsadas por el uso del perfilador de Nsight Systems, dieron como resultado un aumento general de 2,17 veces el rendimiento en tiempo de ejecución.
Resumen
La falta de GPU es un cuello de botella de rendimiento común que puede tener un impacto devastador en la eficiencia y los costos de las cargas de trabajo de IA/ML. En esta publicación, demostramos cómo utilizar el generador de perfiles de Nsight Systems para estudiar las causas del cuello de botella en el rendimiento y tomar medidas informadas para resolverlo. En el camino, enfatizamos las capacidades únicas del perfilador de Nsight Systems en comparación con el PyTorch Profiler integrado centrado en el marco, específicamente su profunda visibilidad a nivel del sistema.
En esta publicación nos centramos en la copia de datos del host al dispositivo que normalmente ocurre al comienzo del paso de capacitación. Sin embargo, los obstáculos en la transferencia de datos pueden aparecer en diferentes etapas de la formación. En una secuela de esta publicación, pretendemos repetir nuestro análisis de perfiles nsys en copias de datos que van en la dirección opuesta: desde el dispositivo hasta el host. ¡Manténganse al tanto!