Report

124x Più Lento: Cosa Fa Effettivamente PyTorch DataLoader a Livello Kernel

mm
Aggiungi Unite.AI alle tue fonti preferite su Google
A conceptual widescreen illustration of an hourglass containing glowing digital data streams and circuit patterns, with the bottom half featuring a large data block and a GPU hardware component sitting idle to the side, representing data processing delays and GPU starvation.

Questo articolo si basa su scoperte da un’indagine di traccia a livello kernel sulla GPU eseguita su un problema reale di PyTorch (#154318) utilizzando eBPF uprobes. I database di traccia sono pubblicati nel repository open-source Ingero per la verifica indipendente.

TL;DR

Il DataLoader di PyTorch può essere 50-124x più lento dell’indicizzazione diretta dei tensori per i carichi di lavoro della GPU in memoria. Abbiamo riprodotto un problema reale di PyTorch su un RTX 4090 e tracciato ogni chiamata API CUDA e evento del kernel Linux per trovare la causa radice. La GPU non era lenta – stava morendo di fame. I lavoratori del DataLoader hanno generato 200.000 commutazioni di contesto CPU e 300.000 allocazioni di pagine in 40 secondi, lasciando la GPU in attesa di una media di 301ms per ogni trasferimento di dati che dovrebbe durare microsecondi.

Il Problema

Un utente di PyTorch ha segnalato che il DataLoader era 7-22x più lento dell’indicizzazione diretta dei tensori per un carico di lavoro di inferenza MLP semplice. Anche con num_workers=12, pin_memory=True e prefetch_factor=12, il divario è rimasto enorme. L’utilizzo della GPU si è attestato al 10-20%.

Abbiamo riprodotto il problema. Il divario era ancora peggiore sul nostro hardware:

Metodo Tempo vs Direct
Indicizzazione diretta dei tensori 0,39s 1x
DataLoader (shuffle=True) 48,49s 124x più lento
DataLoader (ottimizzato, 4 lavoratori, pin_memory) 43,29s 111x più lento

Il carico di lavoro è banale: 7M campioni, 100 caratteristiche, 2-layer MLP, dimensione del batch 1M. Il modello elabora un batch in millisecondi. Quindi, dove va il tempo?

Cosa Mostra nvidia-smi

Niente di utile. L’utilizzo della GPU oscilla tra il 0% e il 30%. L’uso della memoria è stabile. La temperatura è fine. La GPU è chiaramente sottoutilizzata, ma nvidia-smi non può dirti perché.

Cosa Mostra torch.profiler

Il reporter ha provato il profiler integrato di PyTorch e “non ha ottenuto dati di traccia significativi”. Questo è un frustrazione comune – i profiler a livello di applicazione possono mostrarti quali kernel CUDA sono in esecuzione, ma non possono vedere la pianificazione host-side, la memoria e gli eventi di ciclo di vita del processo che determinano se i dati arrivano alla GPU in tempo.

Cosa Mostra la Traccia a Livello Kernel

Abbiamo eseguito il benchmark mentre tracciavamo sia le chiamate API CUDA (tramite eBPF uprobes su libcudart.so) che gli eventi del kernel Linux (commutazioni di contesto del scheduler, allocazioni di pagine della memoria) simultaneamente. I risultati raccontano la storia completa.

Full video walkthrough: https://asciinema.org/a/RGwhPeXAPJdhXqxp

Nel video, abbiamo connesso un LLM open-weight (MiniMax-M2.7) al database di traccia tramite MCP (Model Context Protocol):

ollmcp -m minimax-m2.7:cloud -j /tmp/ingero-mcp-dataloader.json

Il file di configurazione JSON indica al client MCP dove trovare il server Ingero e quale database di traccia caricare:

{
"mcpServers": {
"ingero": {
"command": "./bin/ingero",
"args": ["mcp", "--db", "investigations/pytorch-dataloader-starvation.db"]
}
}
}

Ciò dà all’LLM l’accesso diretto ai dati di traccia attraverso 7 strumenti: get_trace_stats, get_causal_chains, get_per_process_breakdown e altri. L’AI può interrogare il database, correlare gli eventi CUDA con i dati di pianificazione del kernel e produrre una diagnosi in linguaggio piano senza alcun analisi manuale.

4 Catene Causali di Alta Gravità

Il motore di catene causali ha rilevato 4 modelli di alta gravità, tutti con la stessa causa radice:

[HIGH] cudaStreamSync p99=42ms (1,638x p50=25us) - CPU 100% + 1,880 commutazioni di contesto
Timeline:
[SYSTEM] CPU 100%
[HOST ] 1,880 commutazioni di contesto (21s off-CPU)
[CUDA ] p99=42ms (1,638x p50=25us)
Causa radice: lavoratori del DataLoader in lotta per la CPU, pressione di allocazione di pagine massiccia
[HIGH] cudaLaunchKernel p99=24.67ms (349x p50=70us) - CPU 100%
Radice: 34 commutazioni di contesto

[HIGH] cuMemAlloc p99=627us (4.0x p50) - CPU 100%
[HIGH] cuLaunchKernel p99=106us (4.0x p50) - CPU 100%

Il cudaStreamSync p99 è 1.638 volte il p50. Non è lentezza della GPU – è la GPU che aspetta dati che non arrivano in tempo.

Figura 1: Analisi generata dall’AI dopo l’esecuzione di /investigate. Il modello ha utilizzato gli strumenti MCP di Ingero per interrogare il database di traccia e produrre una spiegazione in linguaggio piano con raccomandazioni azionabili derivate direttamente dai dati di traccia.

La Panoramica Per Processo

È qui che le cose si chiariscono. Il processo principale e i suoi 4 lavoratori del DataLoader sono visibili come entità separate:
Processo principale:

- cudaMemcpyAsync (trasferimento host-to-device): media 301ms, massimo 2,9 secondi
- cudaStreamSync: p99 = 42ms (normalmente 25us)
- 1,567 commutazioni di contesto, media 16ms off-CPU, peggior stallo 5 secondi
- 799,018 allocazioni di pagine
Lavoratore del DataLoader 1: 52,863 commutazioni di contesto, 89,338 allocazioni di pagine, peggior stallo 5s
Lavoratore del DataLoader 2: 50,638 commutazioni di contesto, 83,509 allocazioni di pagine, peggior stallo 5s
Lavoratore del DataLoader 3: 49,361 commutazioni di contesto, 70,035 allocazioni di pagine, peggior stallo 5s
Lavoratore del DataLoader 4: 38,862 commutazioni di contesto, 56,354 allocazioni di pagine, peggior stallo 5s

Totale tra i lavoratori: ~191,000 commutazioni di contesto e ~299,000 allocazioni di pagine in 40 secondi.

Cosa Significa

I lavoratori del DataLoader stanno facendo tre cose costose che l’indicizzazione diretta evita completamente:

  1. Shuffling e indicizzazione: Il DataLoader con shuffle=True genera una permutazione casuale degli indici, quindi ogni lavoratore seleziona il proprio chunk. Ciò richiede l’accesso casuale alla memoria across il full 7M-sample tensor – terribile per la località della cache e scatena page faults.
  2. Collazione e copia: Ogni lavoratore raccoglie campioni sparsi in un batch tensor contiguo. Ciò significa allocare nuova memoria (allocazioni di pagine), copiare dati da posizioni casuali (cache misses), e serializzare il risultato indietro al processo principale tramite memoria condivisa o una coda.
  3. Concorrenza per la CPU: Quattro lavoratori + il processo principale su una macchina a 4 vCPU significa preemption costante. Ogni lavoratore viene deschedulato 50,000 volte. Il peggior stallo è di 5 secondi – durante il quale la GPU non ha nulla da elaborare.

Con l’indicizzazione diretta: X[i:i+batch_size] è una vista zero-copy di un tensore contiguo già in memoria. .to(device) scatena un trasferimento DMA da una regione contigua singola. Nessun lavoratore, nessun shuffling, nessuna collazione, nessuna copia cross-process, nessuna commutazione di contesto. La GPU ottiene dati in microsecondi, non in centinaia di millisecondi.

La Soluzione

Per i carichi di lavoro della GPU in memoria dove l’intero set di dati si adatta in RAM:

  1. Non utilizzare il DataLoader. L’indicizzazione diretta con un array di indici pre-shuffled è più semplice e 100x più veloce:
    indices = torch.randperm(num_samples)
    for i in range(0, num_samples, batch_size):
    batch = X[indices[i:i+batch_size]].to(device)
    output = model(batch)
    
  2. Se si deve utilizzare il DataLoader, fare in modo che num_workers corrisponda ai core CPU effettivi meno 1. Su una macchina a 4 core, num_workers=2 riduce la contesa. Aggiungere persistent_workers=True per evitare l’overhead di fork.
  3. Per set di dati più grandi della memoria dove il DataLoader è necessario, il vero collo di bottiglia si sposta verso l’I/O del disco. Utilizzare prefetch_factor=2 (non più alto – più prefetching significa più pressione sulla memoria) e assicurarsi che il proprio storage possa tenere il passo.

Il Quadro Più Ampio

Questa indagine illustra un modello che vediamo costantemente nei carichi di lavoro della GPU: la GPU è veloce, l’host è il collo di bottiglia e le metriche della GPU non possono vederlo. nvidia-smi ha segnalato un utilizzo basso ma non ha potuto spiegare perché. torch.profiler ha catturato i kernel CUDA ma ha perso le 200,000 commutazioni di contesto che stavano accadendo nello spazio utente.

Il modo unico per vedere l’intero quadro era tracciare entrambi i lati simultaneamente – le chiamate API CUDA a livello di libreria e gli eventi del kernel Linux – e correlarli per tempo e ID del processo. La catena causale “CPU 100% -> 1,880 commutazioni di contesto -> cudaMemcpyAsync 301ms -> cudaStreamSync 42ms” racconta la storia completa in una sola riga. Senza traccia cross-stack, questo sarebbe rimasto un mistero – come lo era per il reporter originale che ha trascorso settimane a debugarlo.

Figura 2: Quando chiesto “qual è il problema principale?”, il modello identifica la sovrascrittura della CPU come causa dei ritardi di pianificazione host-side. cudaLaunchKernel è passato da 73us a 25.8ms (356x più lento) perché la CPU non poteva pianificare il lancio in tempo.

Prova Tu Stesso

Riprodurre il benchmark:

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()

# Fast path 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'Direct: {time.time()-start:.3f}s')

# Slow path loader = DataLoader(X, batch_size=1_048_576, shuffle=True) start = time.time() with torch.no_grad(): for batch in loader: model(batch.cuda()) torch.cuda.synchronize() print(f'DataLoader: {time.time()-start:.3f}s')

Tracciare con Ingero per vedere cosa sta succedendo sotto il cofano:

git clone https://github.com/ingero-io/ingero.git
cd ingero && make build
sudo ./bin/ingero trace --duration 60s # in un terminale
python3 benchmark.py # in un altro terminale
./bin/ingero explain --since 60s # dopo il completamento del benchmark

O saltare la riproduzione e esplorare i dati di traccia direttamente. Il database di indagine (764KB) è nel repo:

# Visualizza le catene causali dall'indagine
./bin/ingero explain --db investigations/pytorch-dataloader-starvation.db --since 5m

# Panoramica per processo (vedi lavoratori del DataLoader vs processo principale) ./bin/ingero explain --db investigations/pytorch-dataloader-starvation.db --per-process --since 5m

# Connetti il tuo AI assistant per l'indagine interattiva ./bin/ingero mcp --db investigations/pytorch-dataloader-starvation.db

Indaga con AI (consigliato). Il modo più veloce per analizzare la traccia è connettere qualsiasi AI compatibile con MCP direttamente al database. Nessun analisi manuale necessaria.

Crea un file di configurazione:

cat > /tmp/ingero-mcp-dataloader.json << 'EOF'
{
"mcpServers": {
"ingero": {
"command": "./bin/ingero",
"args": ["mcp", "--db", "investigations/pytorch-dataloader-starvation.db"]
}
}
}
EOF

Quindi connetti il tuo modello:

# Con Ollama + MiniMax (quello che abbiamo usato nel 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 qualsiasi client compatibile con MCP # Aggiungi la configurazione sopra alle impostazioni MCP del tuo AI

Digita /investigate per attivare l’analisi guidata, o chiedi qualsiasi domanda: “Cosa ha causato la fame della GPU?” L’AI ha accesso a 7 strumenti che interrogano il database di traccia direttamente.

GitHub: github.com/ingero-io/ingero
Problema originale: pytorch/pytorch#154318
Video walkthrough: https://asciinema.org/a/RGwhPeXAPJdhXqxp

Indagine eseguita su TensorDock RTX 4090 (24GB), Ubuntu 22.04, PyTorch 2.10.0+cu128.

David Mail è co-autore e manutentore di Ingero, un agente eBPF open-source per l'osservabilità GPU a livello CUDA. Si specializza nel tracciamento a livello del kernel dei carichi di lavoro AI di produzione.