
IA dans plusieurs GPU : comprendre le paradigme de l’hôte et du périphérique
fait partie d’une série sur l’IA distribuée sur plusieurs GPU :
- Partie 1 : Comprendre le paradigme de l’hôte et du périphérique (cet article)
- Partie 2 : Opérations point à point et collectives (à venir)
- Partie 3 : Comment les GPU communiquent (à venir)
- Partie 4 : Accumulation de gradient et parallélisme des données distribuées (DDP) (à venir)
- Partie 5 : ZÉRO (à venir)
- Partie 6 : Parallélisme tensoriel (à venir)
Introduction
Ce guide explique les concepts fondamentaux de la façon dont un processeur et une carte graphique discrète (GPU) fonctionnent ensemble. Il s’agit d’une introduction de haut niveau conçue pour vous aider à construire un modèle mental du paradigme hôte-périphérique. Nous nous concentrerons spécifiquement sur les GPU NVIDIA, qui sont les plus couramment utilisés pour les charges de travail d’IA.
Pour les GPU intégrés, tels que ceux que l’on trouve dans les puces Apple Silicon, l’architecture est légèrement différente et ne sera pas abordée dans cet article.
Vue d’ensemble : l’hôte et l’appareil
Le concept le plus important à comprendre est la relation entre le Hôte et le Appareil.
- L’Hôte : C’est votre Processeur. Il exécute le système d’exploitation et exécute votre script Python ligne par ligne. L’Armée est le commandant ; il est en charge de la logique globale et indique à l’appareil quoi faire.
- L’appareil : C’est votre GPU. Il s’agit d’un coprocesseur puissant mais spécialisé conçu pour les calculs massivement parallèles. L’Appareil est l’accélérateur ; il ne fait rien jusqu’à ce que l’hôte lui confie une tâche.
Votre programme démarre toujours sur le CPU. Lorsque vous souhaitez que le GPU effectue une tâche, comme multiplier deux grandes matrices, le CPU envoie les instructions et les données au GPU.
L’interaction CPU-GPU
L’hôte communique avec l’appareil via un système de file d’attente.
- Le processeur lance des commandes : Votre script, exécuté sur le CPU, rencontre une ligne de code destinée au GPU (par exemple,
tensor.to('cuda')). - Les commandes sont en file d’attente : Le CPU n’attend pas. Il place simplement cette commande sur une liste de tâches spéciale pour le GPU appelée Flux CUDA – plus d’informations à ce sujet dans la section suivante.
- Exécution asynchrone : Le CPU n’attend pas que l’opération proprement dite soit terminée par le GPU, l’hôte passe à la ligne suivante de votre script. Ceci s’appelle exécution asynchroneet c’est une clé pour atteindre des performances élevées. Pendant que le GPU est occupé à traiter des chiffres, le CPU peut travailler sur d’autres tâches, comme préparer le prochain lot de données.
Flux CUDA
UN Flux CUDA est une file d’attente ordonnée d’opérations GPU. Les opérations soumises à un flux unique sont exécutées en ordrel’un après l’autre. Cependant, les opérations à travers différent les flux peuvent exécuter en même temps — le GPU peut jongler avec plusieurs charges de travail indépendantes en même temps.
Par défaut, chaque opération GPU PyTorch est mise en file d’attente sur le flux actif actuel (c’est généralement le flux par défaut qui est automatiquement créé). C’est simple et prévisible : chaque opération attend la fin de la précédente avant de démarrer. Pour la plupart des codes, vous ne le remarquez jamais. Mais cela laisse de côté la performance lorsque vous avez un travail qui pourrait chevaucher.
Flux multiples : concurrence
Le cas d’utilisation classique pour plusieurs flux est chevauchement des calculs et des transferts de données. Pendant que le GPU traite le lot N, vous pouvez simultanément copier le lot N+1 de la RAM CPU vers la VRAM GPU :
Stream 0 (compute): [process batch 0]────[process batch 1]───
Stream 1 (data): ────[copy batch 1]────[copy batch 2]───
Ce pipeline est possible car le calcul et le transfert de données s’effectuent sur des unités matérielles distinctes à l’intérieur du GPU, permettant un véritable parallélisme. Dans PyTorch, vous créez des flux et planifiez le travail sur ceux-ci avec des gestionnaires de contexte :
compute_stream = torch.cuda.Stream()
transfer_stream = torch.cuda.Stream()
with torch.cuda.stream(transfer_stream):
# Enqueue the transfer on transfer_stream
next_batch = next_batch_cpu.to('cuda', non_blocking=True)
with torch.cuda.stream(compute_stream):
# This runs concurrently with the transfer above
output = model(current_batch)
Notez le non_blocking=True drapeau sur .to(). Sans cela, le transfert bloquerait toujours le thread du processeur même si vous souhaitez qu’il s’exécute de manière asynchrone.
Synchronisation entre les flux
Puisque les flux sont indépendants, vous devez signaler explicitement quand l’un dépend d’un autre. L’outil émoussé est :
torch.cuda.synchronize() # waits for ALL streams on the device to finish
Une approche plus chirurgicale utilise Événements CUDA. Un événement marque un point spécifique dans un flux, et un autre flux peut l’attendre sans arrêter le thread CPU :
event = torch.cuda.Event()
with torch.cuda.stream(transfer_stream):
next_batch = next_batch_cpu.to('cuda', non_blocking=True)
event.record() # mark: transfer is done
with torch.cuda.stream(compute_stream):
compute_stream.wait_event(event) # don't start until transfer completes
output = model(next_batch)
C’est plus efficace que stream.synchronize() car cela bloque uniquement le flux dépendant du côté GPU – le thread CPU reste libre pour continuer à mettre en file d’attente le travail.
Pour le code de formation PyTorch quotidien, vous n’aurez pas besoin de gérer les flux manuellement. Mais des fonctionnalités comme DataLoader(pin_memory=True) et la prélecture s’appuient fortement sur ce mécanisme sous le capot. Comprendre les flux vous aide à comprendre pourquoi ces paramètres existent et vous donne les outils nécessaires pour diagnostiquer les goulots d’étranglement subtils des performances lorsqu’ils apparaissent.
Tenseurs PyTorch
PyTorch est un framework puissant qui fait abstraction de nombreux détails, mais cette abstraction peut parfois obscurcir ce qui se passe sous le capot.
Lorsque vous créez un tenseur PyTorch, il comporte deux parties : les métadonnées (comme sa forme et son type de données) et les données numériques réelles. Alors quand tu lances quelque chose comme ça t = torch.randn(100, 100, device=device)les métadonnées du tenseur sont stockées dans la RAM de l’hôte, tandis que ses données sont stockées dans la VRAM du GPU.
Cette distinction est importante. Quand tu cours print(t.shape)le CPU peut accéder immédiatement à ces informations car les métadonnées se trouvent déjà dans sa propre RAM. Mais que se passe-t-il si vous courez print
Synchronisation hôte-périphérique
L'accès aux données du GPU à partir du CPU peut déclencher un Synchronisation hôte-périphériqueun goulot d'étranglement courant en matière de performances. Cela se produit chaque fois que le CPU a besoin d'un résultat du GPU qui n'est pas encore disponible dans la RAM du CPU.
Par exemple, considérons la ligne print(gpu_tensor) qui imprime un tenseur qui est toujours en cours de calcul par le GPU. Le CPU ne peut pas imprimer les valeurs du tenseur tant que le GPU n'a pas terminé tous les calculs pour obtenir le résultat final. Lorsque le script atteint cette ligne, le CPU est obligé de blocc'est-à-dire qu'il s'arrête et attend que le GPU termine. Ce n'est qu'une fois que le GPU a terminé son travail et copié les données de sa VRAM vers la RAM du CPU que celui-ci peut continuer.
Comme autre exemple, quelle est la différence entre torch.randn(100, 100).to(device) et torch.randn(100, 100, device=device)? La première méthode est moins efficace car elle crée les données sur le CPU puis les transfère vers le GPU. La deuxième méthode est plus efficace car elle crée le tenseur directement sur le GPU ; le CPU envoie uniquement la commande de création.
Ces points de synchronisation peuvent avoir un impact important sur les performances. Une programmation GPU efficace implique de les minimiser pour garantir que l'hôte et l'appareil restent aussi occupés que possible. Après tout, vous voulez que vos GPU fonctionnent brrrr.

Mise à l'échelle : calcul distribué et classements
La formation de grands modèles, tels que les grands modèles linguistiques (LLM), nécessite souvent plus de puissance de calcul qu'un seul GPU ne peut offrir. La coordination du travail sur plusieurs GPU vous amène dans le monde de l'informatique distribuée.
Dans ce contexte, un concept nouveau et important émerge : le Rang.
- Chaque rang est un processus CPU auquel est attribué un seul périphérique (GPU) et un identifiant unique. Si vous lancez un script de formation sur deux GPU, vous créerez deux processus : un avec
rank=0et un autre avecrank=1.
Cela signifie que vous lancez deux instances distinctes de votre script Python. Sur une seule machine dotée de plusieurs GPU (un seul nœud), ces processus s'exécutent sur le même CPU mais restent indépendants, sans partage de mémoire ni d'état. Rank 0 commande le GPU qui lui est attribué (cuda:0), alors que Rank 1 commande un autre GPU (cuda:1). Bien que les deux rangs exécutent le même code, vous pouvez exploiter une variable contenant l'ID de rang pour attribuer différentes tâches à chaque GPU, comme demander à chacun de traiter une partie différente des données (nous en verrons des exemples dans le prochain article de blog de cette série).
Conclusion
Félicitations pour avoir lu jusqu'au bout ! Dans cet article, vous avez découvert :
- La relation hôte/appareil
- Exécution asynchrone
- CUDA Streams et comment ils permettent le travail simultané du GPU
- Synchronisation hôte-périphérique
Dans le prochain article de blog, nous approfondirons les opérations point à point et collectives, qui permettent à plusieurs GPU de coordonner des flux de travail complexes tels que la formation de réseaux neuronaux distribués.



