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.jsonIl 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:
- 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.
- 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.
- 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:
- 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)
- 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.
- 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 DataLoaderX = 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.













