Rapports

124x Plus Lent : Ce que PyTorch DataLoader Fait Réellement au Niveau du Noyau

mm
Ajouter Unite.AI à vos sources préférées sur 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.

Cet article est basé sur les résultats d’une enquête de traçage au niveau du noyau de la GPU effectuée sur un problème réel de PyTorch (#154318) en utilisant des eBPF uprobes. Les bases de données de traçage sont publiées dans le référentiel open-source Ingero pour une vérification indépendante.

TL;DR

Le DataLoader de PyTorch peut être 50-124 fois plus lent que l’indexation directe de tenseurs pour les charges de travail de la GPU en mémoire. Nous avons reproduit un problème réel de PyTorch sur un RTX 4090 et avons tracé chaque appel d’API CUDA et chaque événement du noyau Linux pour trouver la cause profonde. La GPU n’était pas lente – elle mourait de faim. Les travailleurs de DataLoader ont généré 200 000 commutations de contexte CPU et 300 000 allocations de pages en 40 secondes, laissant la GPU en attente d’une moyenne de 301 ms par transfert de données qui devrait prendre des microsecondes.

Le Problème

Un utilisateur de PyTorch a signalé que DataLoader était 7-22 fois plus lent que l’indexation directe de tenseurs pour une charge de travail d’inférence MLP simple. Même avec num_workers=12, pin_memory=True et prefetch_factor=12, l’écart est resté massif. L’utilisation de la GPU était de 10-20%.

Nous l’avons reproduit. L’écart était encore pire sur notre matériel :

Méthode Temps Par rapport à l’indexation directe
Indexation directe de tenseurs 0,39 s 1x
DataLoader (shuffle=True) 48,49 s 124 fois plus lent
DataLoader (optimisé, 4 travailleurs, pin_memory) 43,29 s 111 fois plus lent

La charge de travail est triviale : 7 millions d’échantillons, 100 fonctionnalités, 2 couches MLP, taille de lot 1 million. Le modèle traite un lot en millisecondes. Alors, où va le temps ?

Ce que montre nvidia-smi

Rien d’utile. L’utilisation de la GPU oscille entre 0% et 30%. L’utilisation de la mémoire est stable. La température est correcte. La GPU est clairement sous-utilisée, mais nvidia-smi ne peut pas vous dire pourquoi.

Ce que montre torch.profiler

Le signaleur a essayé le profiler intégré de PyTorch et “n’a obtenu aucune donnée de traçage significative”. C’est une frustration courante – les profileurs d’application peuvent vous montrer quels noyaux CUDA sont en cours d’exécution, mais ils ne peuvent pas voir la planification du côté hôte, les événements de mémoire et les événements de cycle de vie des processus qui déterminent si les données arrivent à la GPU à temps.

Ce que montre le traçage au niveau du noyau

Nous avons exécuté le benchmark pendant que nous traçions à la fois les appels d’API CUDA (via des eBPF uprobes sur libcudart.so) et les événements du noyau Linux (commutations de contexte de l’ordonnanceur, allocations de pages de mémoire, forks de processus) simultanément. Les résultats racontent l’histoire complète.

Full vidéo de la marche à suivre : https://asciinema.org/a/RGwhPeXAPJdhXqxp

Dans la vidéo, nous avons connecté un LLM ouvert (MiniMax-M2.7) à la base de données de traçage via MCP (Model Context Protocol) :

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

Le fichier de configuration JSON indique au client MCP où trouver le serveur Ingero et quelle base de données de traçage charger :

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

Cela donne au LLM un accès direct aux données de traçage à travers 7 outils : get_trace_stats, get_causal_chains, get_per_process_breakdown, et d’autres. Le modèle peut interroger la base de données, corréler les événements CUDA avec les données de planification du noyau et produire une explication en langage clair sans analyse manuelle.

4 chaînes de causalité de gravité élevée

Le moteur de chaînes de causalité a détecté 4 modèles de gravité élevée, tous avec la même cause profonde :

[HIGH] cudaStreamSync p99=42ms (1 638 fois p50=25us) - CPU 100% + 1 880 commutations de contexte
Chronologie :
[SYSTEM] CPU 100%
[HOST ] 1 880 commutations de contexte (21 s hors-CPU)
[CUDA ] p99=42ms (1 638 fois p50=25us)
Cause profonde : Les travailleurs de DataLoader se disputent le CPU, pression d'allocation de pages massive
[HIGH] cudaLaunchKernel p99=24,67ms (349 fois p50=70us) - CPU 100%
Racine : 34 commutations de contexte

[HIGH] cuMemAlloc p99=627us (4,0 fois p50) - CPU 100%
[HIGH] cuLaunchKernel p99=106us (4,0 fois p50) - CPU 100%

Le cudaStreamSync p99 est 1 638 fois le p50. Ce n’est pas la lenteur de la GPU – c’est la GPU qui attend des données qui n’arrivent jamais à temps.

Figure 1 : Analyse générée par l’IA après avoir exécuté /investigate. Le modèle a utilisé les 7 outils MCP d’Ingero pour interroger la base de données de traçage et a produit une explication en langage clair avec des recommandations dérivées directement des données de traçage.

La ventilation par processus

C’est ici que les choses deviennent claires. Le processus principal et ses 4 travailleurs de DataLoader sont visibles comme des entités distinctes :

Processus principal :

- cudaMemcpyAsync (transfert hôte-à-appareil) : moyenne 301 ms, maximum 2,9 secondes
- cudaStreamSync : p99 = 42 ms (normalement 25 us)
- 1 567 commutations de contexte, moyenne 16 ms hors-CPU, pire temps d'arrêt 5 secondes
- 799 018 allocations de pages
Travailleur de DataLoader 1 : 52 863 commutations de contexte, 89 338 allocations de pages, pire temps d'arrêt 5 s
Travailleur de DataLoader 2 : 50 638 commutations de contexte, 83 509 allocations de pages, pire temps d'arrêt 5 s
Travailleur de DataLoader 3 : 49 361 commutations de contexte, 70 035 allocations de pages, pire temps d'arrêt 5 s
Travailleur de DataLoader 4 : 38 862 commutations de contexte, 56 354 allocations de pages, pire temps d'arrêt 5 s

Total sur les travailleurs : ~191 000 commutations de contexte et ~299 000 allocations de pages en 40 secondes.

Ce que cela signifie

Les travailleurs de DataLoader font trois choses coûteuses que l’indexation directe évite complètement :

  1. Mélange et indexation : DataLoader avec shuffle=True génère une permutation aléatoire d’indices, puis chaque travailleur sélectionne son morceau. Cela nécessite un accès aléatoire à la mémoire sur le tenseur de 7 millions d’échantillons – terrible pour la localité du cache et déclenche des erreurs de page.
  2. Collation et copie : Chaque travailleur rassemble des échantillons éparpillés dans un tenseur de lot contigu. Cela signifie allouer de nouvelle mémoire (allocations de pages), copier des données de localisations aléatoires (erreurs de cache), et sérialiser le résultat vers le processus principal via une mémoire partagée ou une file d’attente.
  3. Concurrence pour le CPU : Quatre travailleurs + le processus principal sur une machine 4-vCPU signifie une préemption constante. Chaque travailleur est désactivé 50 000 fois. Le pire temps d’arrêt est de 5 secondes – pendant lesquelles la GPU n’a rien à traiter.

Avec l’indexation directe : X[i:i+batch_size] est une vue sans copie d’un tenseur contigu déjà en mémoire. .to(device) déclenche un transfert DMA d’une région contiguë unique. Aucun travailleur, aucun mélange, aucune collation, aucune copie entre processus, aucune commutation de contexte. La GPU reçoit les données en microsecondes, pas en centaines de millisecondes.

La solution

Pour les charges de travail de la GPU en mémoire où l’ensemble du jeu de données tient en RAM :

  1. N’utilisez pas DataLoader. L’indexation directe avec un tableau d’indices pré-mélangeé est plus simple et 100 fois plus rapide :
    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. Si vous devez utiliser DataLoader, faites correspondre num_workers à vos cœurs CPU réels moins 1. Sur une machine 4-cœurs, num_workers=2 réduit la concurrence. Ajoutez persistent_workers=True pour éviter les frais de fork.
  3. Pour les jeux de données plus grands que la mémoire où DataLoader est nécessaire, le véritable goulet d’étranglement passe à l’E/S disque. Utilisez prefetch_factor=2 (et non plus élevé – plus de préchargement signifie plus de pression sur la mémoire) et assurez-vous que votre stockage peut suivre.

Le tableau d’ensemble

Cette enquête illustre un modèle que nous voyons constamment dans les charges de travail de la GPU : la GPU est rapide, l’hôte est le goulet d’étranglement, et les métriques de la GPU ne peuvent pas le voir. nvidia-smi a signalé une faible utilisation mais n’a pas pu expliquer pourquoi. torch.profiler a capturé les noyaux CUDA mais a manqué les 200 000 commutations de contexte se produisant dans l’espace utilisateur.

La seule façon de voir l’image complète était de tracer les deux côtés simultanément – les appels d’API CUDA au niveau de la bibliothèque et les événements de planification du noyau Linux – et de les corréler par temps et ID de processus. La chaîne de causalité “CPU 100% -> 1 880 commutations de contexte -> cudaMemcpyAsync 301 ms -> cudaStreamSync 42 ms” raconte l’histoire complète en une ligne. Sans traçage transpile, cela serait resté un mystère – comme c’était le cas pour le signaleur initial qui a passé des semaines à déboguer.

Figure 2 : Lorsque l’on demande “Quel est le problème de base ?”, le modèle identifie la sur-souscription du CPU comme cause des retards de planification du côté hôte. cudaLaunchKernel est passé de 73 us à 25,8 ms (356 fois plus lent) parce que le CPU n’a pas pu planifier le lancement à temps.

Essayez vous-même

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

# Chemin rapide 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')

# Chemin 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')

Traçez avec Ingero pour voir ce qui se passe sous le capot :

git clone https://github.com/ingero-io/ingero.git
cd ingero && make build
sudo ./bin/ingero trace --duration 60s # dans un terminal
python3 benchmark.py # dans un autre terminal
./bin/ingero explain --since 60s # après la fin du benchmark

Ou sautez la reproduction et explorez directement nos données de traçage. La base de données d’enquête (764 Ko) est dans le référentiel :

# Voir les chaînes de causalité de l'enquête
./bin/ingero explain --db investigations/pytorch-dataloader-starvation.db --since 5m

# Ventilation par processus (voir les travailleurs de DataLoader par rapport au processus principal) ./bin/ingero explain --db investigations/pytorch-dataloader-starvation.db --per-process --since 5m

# Connectez votre assistant d'IA pour une enquête interactive ./bin/ingero mcp --db investigations/pytorch-dataloader-starvation.db

Enquêtez avec l’IA (recommandé). La façon la plus rapide d’analyser la traçage est de connecter n’importe quel IA compatible MCP directement à la base de données. Aucune analyse manuelle n’est nécessaire.

Créez un fichier de configuration :

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

Ensuite, connectez votre modèle :

# Avec Ollama + MiniMax (ce que nous avons utilisé dans la vidéo)
ollmcp -m minimax-m2.7:cloud -j /tmp/ingero-mcp-dataloader.json

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

# Avec n'importe quel client compatible MCP # Ajoutez la configuration ci-dessus à vos paramètres MCP

Tapez /investigate pour déclencher l’analyse guidée, ou posez n’importe quelle question : “Qu’est-ce qui a causé la famine de la GPU ?” L’IA a accès à 7 outils qui interrogent la base de données de traçage directement.

GitHub : github.com/ingero-io/ingero
Problème d’origine : pytorch/pytorch#154318
Vidéo de la marche à suivre : https://asciinema.org/a/RGwhPeXAPJdhXqxp

L’enquête a été réalisée sur TensorDock RTX 4090 (24 Go), Ubuntu 22.04, PyTorch 2.10.0+cu128.

David Mail est co-auteur et mainteneur d'Ingero, un agent eBPF open-source pour l'observabilité GPU de niveau CUDA. Il se spécialise dans le traçage au niveau du noyau des charges de travail d'IA de production.