Rapoarte

124x Mai Lent: Ce Face În Realitate PyTorch DataLoader La Nivel De Kernel

mm
Adaugă Unite.AI la sursele tale preferate pe 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.

Acest articol se bazează pe constatările unei investigații de urmărire la nivel de kernel GPU, efectuată pe o problemă reală PyTorch (#154318) utilizând eBPF uprobes. Bazele de date ale urmăririi sunt publicate în depozitul open-source Ingero pentru verificare independentă.

TL;DR

DataLoader-ul PyTorch poate fi cu 50-124x mai lent decât indexarea directă a tensorilor pentru sarcinile de lucru GPU în memorie. Am reprodus o problemă reală PyTorch pe un RTX 4090 și am urmărit fiecare apel API CUDA și evenimente kernel Linux pentru a găsi cauza rădăcină. GPU-ul nu era lent – era înfometat. Lucrătorii DataLoader au generat 200.000 de comutări de context CPU și 300.000 de alocări de pagină în 40 de secunde, lăsând GPU-ul să aștepte în medie 301ms pentru fiecare transfer de date care ar trebui să dureze microsecunde.

Problema

Un utilizator PyTorch a raportat că DataLoader este cu 7-22x mai lent decât indexarea directă a tensorilor pentru o sarcină de lucru de inferență MLP simplă. Chiar și cu num_workers=12, pin_memory=True și prefetch_factor=12, diferența a rămas uriașă. Utilizarea GPU-ului se situa la 10-20%.

Am reprodus-o. Diferența a fost și mai gravă pe hardware-ul nostru:

Metodă Timp vs Direct
Indexare directă a tensorilor 0,39s 1x
DataLoader (shuffle=True) 48,49s 124x mai lent
DataLoader (optimizat, 4 lucrători, pin_memory) 43,29s 111x mai lent

Sarcina de lucru este trivială: 7M de exemple, 100 de caracteristici, 2 straturi MLP, dimensiunea lotului 1M. Modelul procesează un lot în milisecunde. Deci, unde se duce timpul?

Ce Arată nvidia-smi

Nimic util. Utilizarea GPU-ului variază între 0% și 30%. Utilizarea memoriei este stabilă. Temperatura este în regulă. GPU-ul este clar subutilizat, dar nvidia-smi nu poate spune de ce.

Ce Arată torch.profiler

Raportorul a încercat profiler-ul încorporat PyTorch și “a obținut niciun rezultat util”. Acesta este un frustrare comună – profiler-ii de aplicație pot arăta ce kernel-uri CUDA rulează, dar nu pot vedea programarea gazdă, evenimentele de memorie și ciclul de viață al procesului care determină dacă datele ajung la GPU la timp.

Ce Arată Urmărirea La Nivel De Kernel

Am rulat benchmark-ul în timp ce am urmărit atât apelurile API CUDA (prin eBPF uprobes pe libcudart.so), cât și evenimentele kernel Linux (comutări de context, alocări de pagină, fork-uri de proces) simultan. Rezultatele spun întreaga poveste.

Prezentare video completă: https://asciinema.org/a/RGwhPeXAPJdhXqxp

În videoclip, am conectat un model LLM deschis (MiniMax-M2.7) la baza de date a urmăririi prin MCP (Model Context Protocol):

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

Fișierul de configurare JSON spune clientului MCP unde să găsească serverul Ingero și care bază de date să încarce:

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

Acest lucru oferă modelului LLM acces direct la datele urmăririi prin 7 unelte: get_trace_stats, get_causal_chains, get_per_process_breakdown și altele. Modelul poate interoga baza de date, corela evenimentele CUDA cu datele de programare a kernel-ului și produce o explicație în limbaj simplu fără analiză manuală.

4 Lanțuri Cauzale De Severitate Înaltă

Motorul de lanțuri cauzale a detectat 4 modele de severitate înaltă, toate cu aceeași cauză rădăcină:

[Înalt] cudaStreamSync p99=42ms (1.638x p50=25us) - CPU 100% + 1.880 evenimente de comutare
Cronologie:
[SYSTEM] CPU 100%
[HOST ] 1.880 de comutări de context (21s off-CPU)
[CUDA ] p99=42ms (1.638x p50=25us)
Cauză rădăcină: Lucrătorii DataLoader se luptă pentru CPU, presiune masivă de alocare a paginilor
[Înalt] cudaLaunchKernel p99=24,67ms (349x p50=70us) - CPU 100%
Rădăcină: 34 de evenimente de comutare

[Înalt] cuMemAlloc p99=627us (4,0x p50) - CPU 100%
[Înalt] cuLaunchKernel p99=106us (4,0x p50) - CPU 100%

cudaStreamSync p99 este de 1.638 ori mai mare decât p50. Acesta nu este un semn de lentitudine a GPU-ului – ci de așteptare a datelor care nu sosesc la timp.

Figura 1: Analiză generată de modelul AI după rularea /investigate. Modelul a folosit cele 7 unelte MCP ale Ingero pentru a interoga baza de date a urmăririi și a produs o explicație în limbaj simplu cu recomandări concrete derivate direct din datele urmăririi.

Descompunerea Pe Procese

Aici lucrurile devin clare. Procesul principal și cei 4 lucrători DataLoader sunt vizibili ca entități separate:
Procesul principal:

- cudaMemcpyAsync (transfer de la gazdă la dispozitiv): medie 301ms, maxim 2,9 secunde
- cudaStreamSync: p99 = 42ms (normal 25us)
- 1.567 de comutări de context, medie 16ms off-CPU, cel mai prost blocaj 5 secunde
- 799.018 alocări de pagină
Lucrător DataLoader 1: 52.863 de comutări de context, 89.338 alocări de pagină, cel mai prost blocaj 5s
Lucrător DataLoader 2: 50.638 de comutări de context, 83.509 alocări de pagină, cel mai prost blocaj 5s
Lucrător DataLoader 3: 49.361 de comutări de context, 70.035 alocări de pagină, cel mai prost blocaj 5s
Lucrător DataLoader 4: 38.862 de comutări de context, 56.354 alocări de pagină, cel mai prost blocaj 5s

Total pe lucrători: ~191.000 de comutări de context și ~299.000 de alocări de pagină în 40 de secunde.

Ce Înseamnă Acest Lucru

Lucrătorii DataLoader fac trei lucruri scumpe pe care indexarea directă le evită complet:

  1. Shuffling și indexare: DataLoader cu shuffle=True generează o permutare aleatorie a indicilor, apoi fiecare lucrător selectează fragmentul său. Acest lucru necesită acces aleator la memoria completă a tensorului de 7M de exemple – teribil pentru localitatea cache-ului și declanșează fault-uri de pagină.
  2. Collare și copiere: Fiecare lucrător adună mostre dispersate într-un tensor de lot contiguu. Acest lucru înseamnă alocarea de memorie nouă (alocări de pagină), copierea datelor din locații aleatorii (ratări de cache) și serializarea rezultatului înapoi la procesul principal prin memorie partajată sau o coadă.
  3. Concurență pentru CPU: Patru lucrători + procesul principal pe o mașină cu 4 vCPU înseamnă preemțiune constantă. Fiecare lucrător este deschedulat 50.000 de ori. Cel mai prost blocaj este de 5 secunde – timp în care GPU-ul nu are nimic de procesat.

Cu indexare directă: X[i:i+dimensiunea lotului] este o vedere fără copie a unui tensor contiguu deja în memorie. .to(device) declanșează un singur transfer DMA dintr-o regiune contiguă. Niciun lucrător, niciun shuffling, niciun collare, niciun transfer între procese, nicio comutare de context. GPU-ul primește date în microsecunde, nu în sute de milisecunde.

Repararea

Pentru sarcini de lucru GPU în memorie în care întreaga bază de date se potrivește în RAM:

  1. Nu utilizați DataLoader. Indexarea directă cu un array de indici preșuffle este mai simplă și de 100 de ori mai rapidă:
    indici = torch.randperm(num_samples)
    for i in range(0, num_samples, batch_size):
    batch = X[indici[i:i+batch_size]].to(device)
    output = model(batch)
    
  2. Dacă trebuie să utilizați DataLoader, asigurați-vă că num_workers este egal cu numărul de nuclei CPU minus 1. Pe o mașină cu 4 nuclei, num_workers=2 reduce concurența. Adăugați persistent_workers=True pentru a evita suprasarcina fork.
  3. Pentru baze de date mai mari decât memoria în care DataLoader este necesar, blocajul real se mută la I/O de disc. Utilizați prefetch_factor=2 (nu mai mare – mai multă prefetchare înseamnă mai multă presiune asupra memoriei) și asigurați-vă că stocarea dvs. poate ține pasul.

Imaginea Mai Mare

Această investigație ilustrează un model pe care îl vedem constant în sarcinile de lucru GPU: GPU-ul este rapid, gazda este blocajul, iar metricile GPU nu pot vedea acest lucru. nvidia-smi a raportat o utilizare scăzută, dar nu a putut explica de ce. torch.profiler a capturat kernel-urile CUDA, dar a pierdut cele 200.000 de comutări de context care au avut loc în spațiul utilizator.

Singura modalitate de a vedea imaginea completă a fost de a urmări ambele părți simultan – apelurile API CUDA la nivel de bibliotecă și evenimentele kernel Linux – și de a le corela în funcție de timp și ID de proces. Lanțul cauzal “CPU 100% -> 1.880 comutări de context -> cudaMemcpyAsync 301ms -> cudaStreamSync 42ms” spune întreaga poveste într-o singură linie. Fără urmărire transversală, acest lucru ar fi rămas un mister – așa cum a fost pentru raportorul original care a petrecut săptămâni deblocându-l.

Figura 2: Când i s-a cerut “care este problema de bază?”, modelul identifică suprasarcina CPU care cauzează întârzieri de programare a gazdei. cudaLaunchKernel a trecut de la 73us la 25,8ms (356x mai lent) pentru că CPU-ul nu a putut programa lansarea la timp.

Încercați-Vă Singuri

Reproduceți benchmark-ul:

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

# Cale rapidă 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')

# Cale lentă 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')

Urmăriți cu Ingero pentru a vedea ce se întâmplă sub acoperire:

git clone https://github.com/ingero-io/ingero.git
cd ingero && make build
sudo ./bin/ingero trace --duration 60s # într-un terminal
python3 benchmark.py # într-un alt terminal
./bin/ingero explain --since 60s # după finalizarea benchmark-ului

Sau săriți peste reproducere și explorați direct baza noastră de date a urmăririi:

# Afișați lanțurile cauzale din investigație
./bin/ingero explain --db investigations/pytorch-dataloader-starvation.db --since 5m

# Descompunere pe procese (vedeți lucrătorii DataLoader versus procesul principal) ./bin/ingero explain --db investigations/pytorch-dataloader-starvation.db --per-process --since 5m

# Conectați-vă la asistentul dvs. AI pentru investigație interactivă ./bin/ingero mcp --db investigations/pytorch-dataloader-starvation.db

Investigați cu AI (recomandat). Cea mai rapidă modalitate de a analiza urmărirea este de a conecta orice AI compatibil MCP direct la baza de date. Nu este necesară analiză manuală.

Creați un fișier de configurare:

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

Apoi conectați-vă la model:

# Cu Ollama + MiniMax (ce am folosit în videoclip)
ollmcp -m minimax-m2.7:cloud -j /tmp/ingero-mcp-dataloader.json

# Cu Claude Code claude --mcp-config /tmp/ingero-mcp-dataloader.json

# Cu orice client MCP compatibil # Adăugați configurația de mai sus la setările MCP ale modelului dvs.

Tastați /investigate pentru a declanșa analiza ghidată sau întrebați orice întrebare: “Ce a cauzat înfometarea GPU-ului?” Modelul AI are acces la 7 unelte care interoghează baza de date a urmăririi direct.

GitHub: github.com/ingero-io/ingero
Problemă originală: pytorch/pytorch#154318
Prezentare video: https://asciinema.org/a/RGwhPeXAPJdhXqxp

Investigația a fost efectuată pe TensorDock RTX 4090 (24GB), Ubuntu 22.04, PyTorch 2.10.0+cu128.

David Mail este coautor și întreținător al Ingero, un agent eBPF open-source pentru observabilitate GPU la nivel CUDA. El se specializează în urmărirea la nivel de kernel a sarcinilor de lucru AI de producție.