Este artículo se basa en los resultados de una investigación de rastreo de GPU a nivel de kernel realizada en un problema real de PyTorch (#154318) utilizando sondas de arranque eBPF. Las bases de datos de rastreo se publican en el repositorio de código abierto Ingero para su verificación independiente.
TL; DR
El DataLoader de PyTorch puede ser entre 50 y 124 veces más lento que la indexación directa de tensores para cargas de trabajo de GPU en memoria. Reprodujimos un problema real de PyTorch en una RTX 4090 y rastreamos cada llamada a la API de CUDA y cada evento del kernel de Linux para encontrar la causa raíz. La GPU no era lenta, sino que se estaba quedando sin recursos. Los procesos del DataLoader generaron 200 000 cambios de contexto de CPU y 300 000 asignaciones de páginas en 40 segundos, lo que provocó que la GPU esperara un promedio de 301 ms por transferencia de datos, cuando debería tardar microsegundos.
El problema
Un usuario de PyTorch reportaron DataLoader resultó ser entre 7 y 22 veces más lento que la indexación directa de tensores para una carga de trabajo de inferencia MLP simple. Incluso con num_workers=12, pin_memory=True y prefetch_factor=12, la diferencia seguía siendo enorme. La utilización de la GPU se situó entre el 10 y el 20 %.
Lo reproducimos. La diferencia fue aún mayor en nuestro hardware:
| Método | Hora | vs Direct |
|---|---|---|
| Indexación tensorial directa | Años 0.39 | 1x |
| Cargador de datos (shuffle=True) | Años 48.49 | 124 veces más lento |
| DataLoader (optimizado, 4 trabajadores, pin_memory) | Años 43.29 | 111 veces más lento |
La carga de trabajo es mínima: 7 millones de muestras, 100 características, MLP de 2 capas, tamaño de lote de 1 millón. El modelo procesa un lote en milisegundos. Entonces, ¿en qué se invierte el tiempo?
Lo que muestra nvidia-smi
Nada útil. La utilización de la GPU fluctúa entre el 0 % y el 30 %. El uso de la memoria es estable. La temperatura es normal. Es evidente que la GPU está infrautilizada, pero nvidia-smi no puede indicar el motivo.
Lo que muestra torch.profiler
El reportero probó el generador de perfiles integrado de PyTorch y "no obtuvo datos de seguimiento significativos". Esta es una frustración común: los generadores de perfiles a nivel de aplicación pueden mostrar qué kernels de CUDA se están ejecutando, pero no pueden ver la planificación del lado del host, la memoria y los eventos del ciclo de vida del proceso que determinan si los datos llegan a la GPU a tiempo.
Lo que muestra el rastreo a nivel de kernel
Realizamos la prueba de rendimiento mientras registrábamos simultáneamente las llamadas a la API de CUDA (mediante comandos eBPF uprobes en libcudart.so) y los eventos del kernel de Linux (cambios de contexto del planificador, asignaciones de páginas de memoria, bifurcaciones de procesos). Los resultados lo explican todo.
Tutorial completo en vídeo: https://asciinema.org/a/RGwhPeXAPJdhXqxp
En el vídeo, conectamos un modelo LLM de ponderación abierta (MiniMax-M2.7) a la base de datos de trazas mediante MCP (Protocolo de Contexto del Modelo):
ollmcp -m minimax-m2.7:cloud -j /tmp/ingero-mcp-dataloader.jsonLa configuración JSON le indica al cliente MCP dónde encontrar el servidor Ingero y qué base de datos de rastreo cargar:
{ "mcpServers": { "ingero": { "command": "./bin/ingero", "args": ["mcp", "--db", "investigations/pytorch-dataloader-starvation.db"] } } }
Esto le otorga al LLM acceso directo a los datos de rastreo mediante 7 herramientas: get_trace_stats, get_causal_chains, get_per_process_breakdown y otras. La IA puede consultar la base de datos, correlacionar los eventos CUDA con los datos de planificación del kernel y generar un diagnóstico en lenguaje sencillo sin necesidad de análisis manual.
4 cadenas causales de alta gravedad
El motor de cadena causal detectó 4 patrones de alta gravedad, todos con la misma causa raíz:
[ALTO] cudaStreamSync p99=42ms (1,638x p50=25us) - CPU 100% + 1,880 eventos sched_switch Cronología: [SISTEMA] CPU 100% [HOST] 1,880 cambios de contexto (21 s sin CPU) [CUDA] p99=42 ms (1,638x p50=25 µs) Causa raíz: Los procesos DataLoader compiten por la CPU, presión masiva en la asignación de páginas
[ALTO] cudaLaunchKernel p99=24.67ms (349x p50=70us) - CPU 100% Root: 34 eventos sched_switch [ALTO] cuMemAlloc p99=627us (4.0x p50) - CPU 100% [ALTO] cuLaunchKernel p99=106us (4.0x p50) - CPU 100%
El valor p99 de cudaStreamSync es 1,638 veces mayor que el valor p50. Esto no se debe a la lentitud de la GPU, sino a que la GPU está esperando datos que nunca llegan a tiempo.
Figura 1: Análisis generado por IA tras ejecutar /investigate. El modelo utilizó las 7 herramientas MCP de Ingero para consultar la base de datos de rastreo y generó una explicación en lenguaje sencillo con recomendaciones prácticas derivadas directamente de los datos de rastreo.

Desglose por proceso
Aquí es donde queda claro. El proceso principal y sus 4 trabajadores DataLoader se visualizan como entidades separadas:
Proceso principal:
- cudaMemcpyAsync (transferencia de host a dispositivo): promedio 301 ms, máximo 2.9 segundos - cudaStreamSync: p99 = 42 ms (normalmente 25 µs) - 1,567 cambios de contexto, promedio 16 ms fuera de la CPU, peor bloqueo 5 segundos - 799 018 asignaciones de páginas
Trabajador DataLoader 1: 52,863 cambios de contexto, 89,338 asignaciones de página, peor tiempo de espera 5 s Trabajador DataLoader 2: 50,638 cambios de contexto, 83,509 asignaciones de página, peor tiempo de espera 5 s Trabajador DataLoader 3: 49,361 cambios de contexto, 70,035 asignaciones de página, peor tiempo de espera 5 s Trabajador DataLoader 4: 38,862 cambios de contexto, 56,354 asignaciones de página, peor tiempo de espera 5 s
Total entre los trabajadores: ~191,000 cambios de contexto y ~299,000 asignaciones de páginas en 40 segundos.
Lo que esto significa
Los trabajadores de DataLoader realizan tres tareas costosas que la indexación directa evita por completo:
- Barajar e indexar: DataLoader con shuffle=True genera una permutación aleatoria de índices, y luego cada trabajador selecciona su fragmento. Esto requiere acceso aleatorio a la memoria en todo el tensor de 7 millones de muestras, lo cual es terrible para la localidad de la caché y provoca fallos de página.
- Cotejo y copiado: Cada trabajador reúne muestras dispersas en un tensor de lotes contiguo. Esto implica asignar nueva memoria (asignación de páginas), copiar datos de ubicaciones aleatorias (fallos de caché) y serializar el resultado de vuelta al proceso principal a través de memoria compartida o una cola.
- Compitiendo por la CPU: Cuatro procesos de trabajo más el proceso principal en una máquina con 4 vCPU implican una interrupción constante. Cada proceso de trabajo se desprograma 50 000 veces. El peor escenario de inactividad es de 5 segundos, durante los cuales la GPU no tiene nada que procesar.
Con indexación directa: X[i:i+batch_size] es una vista sin copias de un tensor contiguo que ya está en memoria. .to(device) activa una transferencia DMA desde una única región contigua. Sin trabajadores, sin reordenamiento, sin intercalación, sin copias entre procesos, sin cambios de contexto. La GPU recibe los datos en microsegundos, no en cientos de milisegundos.
The Fix
Para cargas de trabajo de GPU en memoria donde todo el conjunto de datos cabe en la RAM:
- No utilice DataLoader. La indexación directa con una matriz de índices previamente barajada es más sencilla y 100 veces más rápida:
índices = torch.randperm(num_muestras) para i en range(0, num_muestras, tamaño_lote): lote = X[índices[i:i+tamaño_lote]].to(dispositivo) salida = modelo(lote)
- Si debe utilizar DataLoader, Ajusta num_workers al número real de núcleos de tu CPU menos 1. En una máquina de 4 núcleos, num_workers=2 reduce la contención. Añade persistent_workers=True para evitar la sobrecarga de fork.
- Para conjuntos de datos que superan la capacidad de la memoria Cuando DataLoader es necesario, el verdadero cuello de botella se traslada a las operaciones de entrada/salida del disco. Utilice prefetch_factor=2 (no un valor superior, ya que una mayor precarga implica una mayor presión sobre la memoria) y asegúrese de que su sistema de almacenamiento pueda soportar el ritmo.
The Bigger Picture
Esta investigación ilustra un patrón que observamos constantemente en las cargas de trabajo de GPU: la GPU es rápida, el host es el cuello de botella y las métricas de la GPU no lo detectan. nvidia-smi informó una baja utilización, pero no pudo explicar el motivo. torch.profiler capturó los kernels de CUDA, pero no registró los 200 000 cambios de contexto que se producían en el espacio de usuario.
La única forma de comprender el panorama completo era rastrear ambos lados simultáneamente: las llamadas a la API de CUDA a nivel de biblioteca y los eventos de planificación del kernel de Linux, y correlacionarlos por tiempo e ID de proceso. La cadena causal "CPU 100% -> 1,880 sched_switch -> cudaMemcpyAsync 301ms -> cudaStreamSync 42ms" lo explica todo en una sola línea. Sin el rastreo de pila cruzada, esto habría permanecido sin resolver, como le sucedió al reportero original que pasó semanas depurándolo.
Figura 2: Al preguntarle "¿cuál es el problema principal?", el modelo identifica la sobrecarga de la CPU como causa de retrasos en la programación del host. cudaLaunchKernel pasó de 73 µs a 25.8 ms (356 veces más lento) porque la CPU no pudo programar el lanzamiento a tiempo.

Inténtalo tú mismo
Reproduzca la prueba de rendimiento:
import torch, time from torch.utils.data import DataLoader X = torch.randn(7_000_000, 100) model = torch.nn.Sequential( torch.nn.Linear(100, 512), torch.nn.ReLU(), torch.nn.Linear(512, 512), torch.nn.ReLU(), torch.nn.Linear(512, 10) ).cuda() # Ruta rápida start = time.time() with torch.no_grad(): for i in range(0, len(X), 1_048_576): model(X[i:i+1_048_576].cuda()) torch.cuda.synchronize() print(f'Directo: {time.time()-start:.3f}s') # Ruta lenta loader = DataLoader(X, batch_size=1_048_576, shuffle=True) start = time.time() con torch.no_grad(): para batch en loader: model(batch.cuda()) torch.cuda.synchronize() print(f'DataLoader: {time.time()-start:.3f}s')
Rastrea con Ingero para ver qué sucede bajo el capó:
git clone https://github.com/ingero-io/ingero.git cd ingero && make build sudo ./bin/ingero trace --duration 60s # en una terminal python3 benchmark.py # en otra terminal ./bin/ingero explain --since 60s # después de que finalice la prueba de rendimiento
O bien, omita la reproducción y explore directamente nuestros datos de rastreo. La base de datos de la investigación (764 KB) se encuentra en el repositorio:
# Visualiza las cadenas causales de la investigación ./bin/ingero explain --db investigations/pytorch-dataloader-starvation.db --since 5m # Desglose por proceso (ver procesos de DataLoader vs. proceso principal) ./bin/ingero explain --db investigations/pytorch-dataloader-starvation.db --per-process --since 5m # Conecta tu asistente de IA para una investigación interactiva ./bin/ingero mcp --db investigations/pytorch-dataloader-starvation.db
Investigar con IA (recomendado). La forma más rápida de analizar el rastro es conectar cualquier IA compatible con MCP directamente a la base de datos. No se requiere análisis manual.
Crea un archivo de configuración:
cat > /tmp/ingero-mcp-dataloader.json << 'EOF' { "mcpServers": { "ingero": { "command": "./bin/ingero", "args": ["mcp", "--db", "investigations/pytorch-dataloader-starvation.db"] } } } EOF
Luego conecta tu modelo:
# Con Ollama + MiniMax (lo que usamos en el video) ollmcp -m minimax-m2.7:cloud -j /tmp/ingero-mcp-dataloader.json # Con Claude Code claude --mcp-config /tmp/ingero-mcp-dataloader.json # Con cualquier cliente compatible con MCP # Agregue la configuración anterior a la configuración MCP de su IA
Escribe /investigate para activar el análisis guiado o formula cualquier pregunta: "¿Qué causó la falta de recursos de la GPU?". La IA tiene acceso a 7 herramientas que consultan directamente la base de datos de rastreo.
GitHub: github.com/ingero-io/ingero
Número original: pytorch/pytorch#154318
Tutorial en vídeo: https://asciinema.org/a/RGwhPeXAPJdhXqxp
Investigación realizada en TensorDock RTX 4090 (24 GB), Ubuntu 22.04, PyTorch 2.10.0+cu128.













