Chapitre 0

Introduction à PyTorch

Chapitre 0 : Introduction pratique à PyTorch, tenseurs, graphes de calcul, et autograd.

Auteur Léonel VODOUNOU
Dossier Build a Large Language Model (from Scratch)
Publié le June 27, 2026
Temps de lecture 69 min read

Introduction

Bienvenue dans le Chapitre 0 de notre série dédiée à la construction d’un LLM (Large Language Model) de zéro. Avant de plonger dans les mécanismes d’attention et l’architecture des Transformers, nous devons maîtriser notre outil de travail principal : PyTorch.

PyTorch est une bibliothèque de Deep Learning extrêmement populaire, prisée pour sa flexibilité et sa manipulation intuitive des tenseurs. Dans ce chapitre préliminaire, nous allons explorer les concepts fondamentaux qui nous serviront de fondation pour tout le reste du projet :

  • La création et la manipulation des tenseurs (les structures de données au cœur des réseaux de neurones).
  • Le fonctionnement des graphes de calcul.
  • La magie de l’Autograd (le moteur de différenciation automatique qui permet aux modèles d’apprendre via la descente de gradient).

Si vous êtes déjà à l’aise avec PyTorch, vous pouvez survoler ce chapitre ou passer directement au Chapitre 1. Sinon, ouvrez votre environnement Python (ou Google Colab), et commençons !

import torch
torch.__version__
'2.10.0+cu128'
import torch
torch.cuda.is_available()
True

Comprendre les tenseurs

tensor0d = torch.tensor(1)

tensor1d = torch.tensor([1, 2, 3])

tensor2d = torch.tensor([[1, 2, 3], 
                         [4, 5, 6]])

tensor3d = torch.tensor([[[1, 2], [3, 4]], 
                         [[5, 6], [8, 9]]])
print(tensor1d.dtype)
torch.int64
float_tensor = torch.tensor([1.0, 2.0, 3.0])
print(float_tensor.dtype)
torch.float32

Les nombres à virgule flottante 32 bits offrent une précision suffisante pour la plupart des tâches de Deep Learning tout en consommant moins de mémoire et de ressources de calcul que les nombres à virgule flottante 64 bits. De plus, les architectures de GPU sont optimisées pour les calculs 32 bits, et l’utilisation de ce type de données peut accélérer considérablement l’entraînement du modèle et l’inférence. En outre, il est possible de modifier la précision à l’aide de la méthode ‘.to’ d’un tensor.

float_tensor = float_tensor.to(torch.float32)
print(float_tensor.dtype)
torch.float32
print(tensor2d)
tensor([[1, 2, 3],
        [4, 5, 6]])

L’attribut .shape nous permet d’accéder à la forme (shape) d’un tensor :

print(tensor2d.shape)
torch.Size([2, 3])

Pour remodeler le tensor en un tensor 3 × 2, nous pouvons utiliser la méthode .reshape :

print(tensor2d.reshape([3, 2]))
tensor([[1, 2],
        [3, 4],
        [5, 6]])

Cependant, notez que la commande la plus courante pour remodeler les tenseurs dans PyTorch est .view()

print(tensor2d.view([3, 2]))
tensor([[1, 2],
        [3, 4],
        [5, 6]])

La différence subtile entre .view() et .reshape() dans PyTorch réside dans leur gestion de la disposition de la mémoire : .view() nécessite que les données d’origine soient contiguës et échouera si ce n’est pas le cas, tandis que .reshape() fonctionnera dans tous les cas, en copiant les données si nécessaire pour assurer la forme souhaitée.

Oubliez les mathématiques un instant et imaginons comment fonctionne la mémoire physique de votre ordinateur (la RAM ou la VRAM).

Qu’est-ce que les “données physiques” ?

La mémoire de votre ordinateur n’a pas de concept de “tableau en 2D” ou de “cube en 3D”. La mémoire est simplement une très longue ligne droite de petites boîtes.

Imaginons que vous créez un tenseur PyTorch simple, un tableau de 2 lignes et 2 colonnes :

t = torch.tensor([[1, 2],
                  [3, 4]])

Visuellement (Logique), vous voyez un carré. Physiquement (dans la RAM), PyTorch range ces nombres dans 4 boîtes alignées l’une derrière l’autre : [1, 2, 3, 4].

Quand PyTorch veut lire la première ligne [1, 2], il lit les deux premières boîtes. Pour la deuxième ligne [3, 4], il lit les deux suivantes. Puisque l’ordre de lecture logique correspond exactement à l’ordre des boîtes physiques, on dit que c’est contigu (bien rangé).


Comment un tenseur devient “non-contigu” (modifié de façon complexe) ?

Maintenant, appliquons une opération mathématique, par exemple la transposition (qui échange les lignes et les colonnes).

t_transpose = t.t()
# Résultat logique (ce que vous voyez à l'écran) :
# [[1, 3],
#  [2, 4]]

Voici le secret de PyTorch pour être ultra-rapide : il ne déplace absolument rien dans les boîtes physiques ! Dans la RAM, les boîtes sont toujours dans l’ordre : [1, 2, 3, 4].

Pour vous afficher [[1, 3], [2, 4]], PyTorch a juste ajouté une note interne qui dit : “Attention, pour lire la première ligne, prends la boîte n°1, puis saute par-dessus la n°2 pour aller chercher la boîte n°3.”

Puisque PyTorch doit maintenant “sauter” d’une boîte à l’autre pour lire le tableau correctement, l’ordre de lecture ne correspond plus à l’ordre physique des boîtes. Le tenseur est devenu non-contigu. C’est cela une “modification complexe” : une opération qui change la forme logique sans toucher au rangement physique. (Couper un bout du tenseur avec du slicing comme t[:, 1] fait exactement la même chose).


Le drame avec .view()

La fonction .view() sert à donner une nouvelle forme à un tenseur (par exemple, transformer notre 2x2 en une ligne de 4). Mais .view() a une limitation technique : elle refuse de travailler si elle doit sauter des boîtes. Elle veut lire les boîtes sagement de gauche à droite.

Si vous faites t_transpose.view(4), PyTorch panique : “Hé, ce tenseur me demande de faire des sauts dans la mémoire physique, je ne peux pas faire un .view() là-dessus !” -> Erreur (Crash).


Le côté magique de .reshape()

La fonction .reshape(4) est beaucoup plus intelligente. Quand vous la lancez sur t_transpose :

  1. Elle regarde la mémoire physique et réalise : “Ah, il y a des sauts, c’est non-contigu.”
  2. Au lieu de crasher, elle va secrètement construire de nouvelles boîtes physiques quelque part ailleurs dans la RAM et les ranger dans le bon ordre direct : [1, 3, 2, 4].
  3. Maintenant que c’est propre, elle applique la nouvelle forme. -> Succès, aucun crash.

C’est pour ça qu’on dit que .reshape() “copie les données si nécessaire”, et que c’est la méthode recommandée pour être tranquille !

Nous pouvons utiliser .T pour transposer un tensor, ce qui signifie le retourner par rapport à sa diagonale

print(tensor2d.T)
tensor([[1, 4],
        [2, 5],
        [3, 6]])

La façon courante de multiplier deux matrices dans PyTorch est la méthode .matmul

print(tensor2d.matmul(tensor2d.T))
tensor([[14, 32],
        [32, 77]])

Voir les modèles comme des graphes de calcul

Le système autograd de PyTorch fournit des fonctions pour calculer automatiquement les gradients dans des graphes de calcul dynamiques. Un graphe de calcul est un graphe orienté qui nous permet d’exprimer et de visualiser des expressions mathématiques. Dans le contexte du Deep Learning, un graphe de calcul détaille la séquence de calculs nécessaires pour calculer la sortie d’un réseau de neurones — nous en aurons besoin pour calculer les gradients requis pour la backpropagation, l’algorithme d’entraînement principal pour les réseaux de neurones.

Un forward pass de régression logistique

Forward pass d’une régression logistique représenté comme un graphe de calcul. La caractéristique d’entrée x1 est multipliée par un poids du modèle w1 et passée à travers une fonction d’activation σ après avoir ajouté le biais. La loss est calculée en comparant la sortie du modèle a avec un label donné y.

# Cette instruction d'importation est une convention courante dans PyTorch pour éviter les longues lignes de code
import torch.nn.functional as F

# Label réel
y = torch.tensor([1.0])

# Caractéristique d'entrée
x1 = torch.tensor([1.1])

# Paramètre de poids
w1 = torch.tensor([2.2])

# Unité de biais
b = torch.tensor([0.0])

# Entrée nette
z1 = x1 * w1 + b

# Activation et sortie 
a = torch.sigmoid(z1)

loss = F.binary_cross_entropy(a, y)

La différenciation automatique rendue facile

Si nous effectuons des calculs dans PyTorch, il construira par défaut un graphe de calcul en interne si l’un de ses nœuds terminaux possède l’attribut requires_grad défini sur True. Cela s’avère utile si nous souhaitons calculer des gradients. Les gradients sont indispensables lors de l’entraînement des réseaux de neurones via le célèbre algorithme de rétropropagation (ou backpropagation), que l’on peut considérer comme une application de la règle de dérivation en chaîne (chain rule) du calcul différentiel pour les réseaux de neurones, comme l’illustre la figure ci-dessous :

La méthode la plus courante pour calculer les gradients de la loss dans un graphe de calcul consiste à appliquer la règle de dérivation en chaîne (chain rule) de droite à gauche, également appelée différenciation automatique en mode inverse ou backpropagation. Nous partons de la couche de sortie (ou de la loss elle-même) et remontons à travers le réseau jusqu’à la couche d’entrée. Nous faisons cela pour calculer le gradient de la loss par rapport à chaque paramètre (poids et biais) du réseau, ce qui nous indique comment mettre à jour ces paramètres pendant l’entraînement.

  • Les dérivées partielles mesurent la vitesse à laquelle une fonction change par rapport à l’une de ses variables.

  • Un gradient est un vecteur contenant toutes les dérivées partielles d’une fonction multivariée (une fonction avec plus d’une variable en entrée).

Pour faire simple, tout ce que vous devez savoir, c'est que la règle de dérivation en chaîne (chain rule) est un moyen de calculer les gradients d'une fonction de loss étant donné les paramètres du modèle dans un graphe de calcul. Cela fournit les informations nécessaires pour mettre à jour chaque paramètre afin de minimiser la fonction de loss (qui sert de proxy pour mesurer les performances du modèle en utilisant une méthode telle que la descente de gradient).

Le moteur autograd de PyTorch construit un graphe de calcul en arrière-plan en suivant chaque opération effectuée sur les tenseurs. Ensuite, en appelant la fonction grad, nous pouvons calculer le gradient de la loss concernant le paramètre de modèle w1, comme indiqué dans le listing suivant.

import torch.nn.functional as F
from torch.autograd import grad

y = torch.tensor([1.0])
x1 = torch.tensor([1.1])
w1 = torch.tensor([2.2], requires_grad=True)
b = torch.tensor([0.0], requires_grad=True)

z1 = x1 * w1 + b
a = torch.sigmoid(z1)

loss = F.binary_cross_entropy(a, y)

Par défaut, PyTorch détruit le graphe de calcul après avoir calculé les gradients pour libérer de la mémoire. Cependant, comme nous réutiliserons ce graphe de calcul sous peu, nous définissons retain_graph=True pour qu’il reste en mémoire.


grad_L_w1 = grad(loss, w1, retain_graph=True)
grad_L_b = grad(loss, b, retain_graph=True)
print(grad_L_w1)
print(grad_L_b)
(tensor([-0.0898]),)
(tensor([-0.0817]),)

Ici, nous avons utilisé la fonction grad manuellement, ce qui peut être utile pour l’expérimentation, le débogage et la démonstration de concepts.

Mais, en pratique, PyTorch fournit des outils de plus haut niveau pour automatiser ce processus. Par exemple, nous pouvons appeler .backward sur la loss, et PyTorch calculera les gradients de tous les nœuds feuilles dans le graphe, qui seront stockés via les attributs .grad des tenseurs.

loss.backward()
print(w1.grad)
print(b.grad)
tensor([-0.0898])
tensor([-0.0817])

Implémenter un réseau de neurones multicouche

Jetons un œil à un perceptron multicouche, un réseau de neurones entièrement connecté, comme illustré dans la figure ci-dessous :

Un perceptron multicouche avec deux couches cachées. Chaque nœud représente une unité dans la couche respective. À des fins d’illustration, chaque couche a un très petit nombre de nœuds.

Lors de l’implémentation d’un réseau de neurones dans PyTorch, nous pouvons créer une sous-classe de la classe torch.nn.Module pour définir notre propre architecture de réseau personnalisée. Cette classe de base Module fournit de nombreuses fonctionnalités, rendant plus facile la construction et l’entraînement des modèles. Par exemple, elle nous permet d’encapsuler les couches et les opérations et de garder une trace des paramètres du modèle.

Au sein de cette sous-classe, nous définissons les couches du réseau dans le constructeur __init__ et spécifions comment les couches interagissent dans la méthode forward.

La méthode forward décrit comment les données d’entrée traversent le réseau et s’assemblent sous forme d’un graphe de calcul. En revanche, la méthode backward, que nous n’avons généralement pas besoin d’implémenter nous-mêmes, est utilisée pendant l’entraînement pour calculer les gradients de la fonction de loss étant donné les paramètres du modèle.

class NeuralNetwork(torch.nn.Module):
    """Coding the number of inputs and oututs as variables allows us to reuse the same code for datasets with different numbers of features and classes."""

    def __init__(self, num_inputs, num_outputs):
        super().__init__()

        self.layers = torch.nn.Sequential(
            # 1st hidden layer 
            torch.nn.Linear(num_inputs, 30), # The Linear layer takes the number of input and output nodes as arguments
            torch.nn.ReLU(), # Nonlinear activation functions are placed between the hidden layers

            # 2nd hidden layer
            torch.nn.Linear(30, 20), # The number of output nodes of one hidden layer has to match the number of inputs of the next layer
            torch.nn.ReLU(),

            # output layer
            torch.nn.Linear(20, num_outputs), 
        )

    def forward(self, x):
        logits = self.layers(x)
        return logits # The outputs of the last layers are called logits

torch.nn.Sequential n’est pas obligatoire. C’est simplement un raccourci très pratique (“une boîte”) pour regrouper des couches qui s’exécutent les unes à la suite des autres en ligne droite.

L’alternative classique, qui est même la méthode la plus courante consiste à définir chaque couche individuellement, puis à relier les tuyaux vous-même dans la fonction forward().

class NeuralNetwork(torch.nn.Module):

    def __init__(self, num_inputs, num_outputs):
        super().__init__()

        # On définit toutes nos couches comme des attributs séparés
        self.layer1 = torch.nn.Linear(num_inputs, 30)
        self.relu1 = torch.nn.ReLU()
        
        self.layer2 = torch.nn.Linear(30, 20)
        self.relu2 = torch.nn.ReLU()
        
        self.output_layer = torch.nn.Linear(20, num_outputs)

    def forward(self, x):
        # On fait passer manuellement les données 'x' d'une couche à l'autre
        x = self.layer1(x)
        x = self.relu1(x)
        
        x = self.layer2(x)
        x = self.relu2(x)
        
        logits = self.output_layer(x)
        
        return logits
# We can now create an instance of our NeuralNetwork class and print it to see its architecture.
model = NeuralNetwork(50, 30)
print(model)
NeuralNetwork(
  (layer1): Linear(in_features=50, out_features=30, bias=True)
  (relu1): ReLU()
  (layer2): Linear(in_features=30, out_features=20, bias=True)
  (relu2): ReLU()
  (output_layer): Linear(in_features=20, out_features=30, bias=True)
)

1. model.parameters()

Méthode obtenu en héritant de torch.nn.Module, quand nous appelons model.parameters(), PyTorch parcourt de lui-même toutes les couches de notre réseau (nos nn.Linear, qu’elles soient dans un Sequential ou non).

Pour chaque couche Linear, il “récupère” deux choses essentielles :

  1. La matrice des Poids (weights) (qui relie l’entrée à la sortie).
  2. Le vecteur des Biais (biases) (Les fonctions d’activation comme ReLU n’ont aucun poids ni paramètre, elles sont justes ignorées !)

Il nous renvoie une liste (un générateur, plus exactement) contenant chacun de ces gros tableaux mathématiques (les tenseurs).

2. for p in ...

La boucle parcourt cette liste. À chaque tour, p est un tenseur (une matrice de poids ou un vecteur de biais).

3. p.numel()

Comme nous l’avons vu, il compte le nombre total de valeurs à l’intérieur du tenseur p. Par exemple :

  • Si p est la matrice de poids de la 1re couche (Linear(50, 30)), il compte 50×30=150050 \times 30 = 1500 éléments.
  • Si p est le biais de cette 1re couche, il compte 3030 éléments.
  • Total pour cette couche : 1530 paramètres.

4. if p.requires_grad (Le détail crucial)

C’est un filtre très astucieux. Il dit à la boucle : “Ne compte que les paramètres que l’optimiseur a le droit de modifier pendant l’entraînement (requires_grad=True)”.

# To calculate the total number of trainable parameters in the model, we can use the following code:
num_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print("Total number of trainable model parameters:", num_params)
Total number of trainable model parameters: 2780
# We can access the corresponding weight parameter matrix as dollows :
print(model.layer1.weight)
Parameter containing:
tensor([[ 0.0220,  0.0694,  0.0166,  ..., -0.0385,  0.0019, -0.1359],
        [-0.0732,  0.0306, -0.0902,  ..., -0.0025,  0.0096,  0.0459],
        [ 0.0650,  0.0479, -0.0650,  ...,  0.0457,  0.0547,  0.0530],
        ...,
        [-0.1018,  0.0198, -0.0843,  ...,  0.0490,  0.0630, -0.0015],
        [ 0.0263, -0.0795, -0.1332,  ..., -0.0489,  0.1156,  0.1180],
        [ 0.0757, -0.0454, -0.1383,  ..., -0.0881, -0.1151,  0.0419]],
       requires_grad=True)
# lets use the .shape to show it dimension
print(model.layer1.weight.shape)
torch.Size([30, 50])
# We can access the bais vector via :
print(model.layer1.bias)
Parameter containing:
tensor([ 0.1108,  0.0739, -0.0027, -0.0209,  0.0596, -0.0188, -0.1029, -0.0371,
         0.0450, -0.0449,  0.0426,  0.1201,  0.0831, -0.1402,  0.1035,  0.0408,
        -0.0525, -0.0613,  0.1077,  0.0226,  0.0002,  0.1366, -0.0544,  0.0772,
         0.0889, -0.1245,  0.0762,  0.0724,  0.0690,  0.0500],
       requires_grad=True)

Nous pouvons rendre l’initialisation des nombres aléatoires reproductible en fixant la graine du générateur aléatoire de PyTorch via manual_seed

torch.manual_seed(123)
model = NeuralNetwork(50, 3)
print(model.layer1.weight)
Parameter containing:
tensor([[-0.0577,  0.0047, -0.0702,  ...,  0.0222,  0.1260,  0.0865],
        [ 0.0502,  0.0307,  0.0333,  ...,  0.0951,  0.1134, -0.0297],
        [ 0.1077, -0.1108,  0.0122,  ...,  0.0108, -0.1049, -0.1063],
        ...,
        [-0.0787,  0.1259,  0.0803,  ...,  0.1218,  0.1303, -0.1351],
        [ 0.1359,  0.0175, -0.0673,  ...,  0.0674,  0.0676,  0.1058],
        [ 0.0790,  0.1343, -0.0293,  ...,  0.0344, -0.0971, -0.0509]],
       requires_grad=True)

Voyons brièvement comment NeuralNetwork est utilisé via la passe forward

torch.manual_seed(123)
X = torch.rand(1, 50)
out = model(X)
print(out)
tensor([[-0.1262,  0.1080, -0.1792]], grad_fn=<AddmmBackward0>)

La passe forward fait référence au calcul des tensors de sortie à partir des tensors d’entrée. Cela implique de faire passer les données d’entrée à travers toutes les couches du réseau de neurones, en commençant par la couche d’entrée, à travers les couches cachées, et finalement jusqu’à la couche de sortie.

Ces trois nombres renvoyés ici correspondent à un score attribué à chacun des trois nœuds de sortie. Remarquez que le tensor de sortie inclut également une valeur grad_fn.

Ici, grad_fn =<AddmmBackward0> représente la dernière fonction utilisée pour calculer une variable dans le graphe de calcul. PyTorch utilisera cette information lorsqu’il calcule les gradients lors de la backpropagation. Dans ce cas, c’est une opération Addmm. Addmm signifie multiplication de matrices (mm) suivie d’une addition (Add).

Lorsque nous utilisons un modèle pour l’inference (par exemple, pour faire des prédictions) plutôt que pour l’entraînement, la bonne pratique est d’utiliser le gestionnaire de contexte torch.no_grad(). Cela indique à PyTorch qu’il n’a pas besoin de garder une trace des gradients, ce qui peut entraîner des économies significatives en mémoire et en calcul.

with torch.no_grad():
    out = model(X)
print(out)
tensor([[-0.1262,  0.1080, -0.1792]])

Dans PyTorch, c’est une pratique courante de coder les modèles de telle sorte qu’ils renvoient les sorties de la dernière couche (logits) sans les passer par une fonction d’activation non linéaire. C’est parce que les fonctions de loss couramment utilisées dans PyTorch combinent l’opération softmax (ou sigmoid pour la classification binaire) avec la loss de log-vraisemblance négative dans une seule classe. La raison de ceci est l’efficacité numérique et la stabilité. Donc, si nous voulons calculer les probabilités d’appartenance aux classes pour nos prédictions, nous devons appeler la fonction softmax explicitement.

with torch.no_grad():
    out = torch.softmax(model(X), dim=1)
print(out)
tensor([[0.3113, 0.3934, 0.2952]])

Mettre en place des data loaders efficaces

L’idée générale derrière le chargement des données dans PyTorch est illustrée dans la figure ci-dessous :

PyTorch implémente une classe Dataset et une classe DataLoader. La classe Dataset est utilisée pour instancier des objets qui définissent comment chaque enregistrement de données est chargé. Le DataLoader gère la manière dont les données sont mélangées et assemblées en batchs.

X_train = torch.tensor([
    [-1.2, 3.1],
    [-0.9, 2.9],
    [-0.5, 2.6],
    [2.3, -1.1],
    [2.7, -1.5]
])

y_train = torch.tensor([0, 0, 0, 1, 1])

X_test = torch.tensor([
    [-0.8, 2.8],
    [2.6, -1.6]
])

y_test = torch.tensor([0, 1])

NB : PyTorch exige que les labels de classe commencent par le label 0, et la plus grande valeur de label de classe ne doit pas dépasser le nombre de nœuds de sortie moins 1 (puisque le comptage des indices en Python commence à zéro). Donc, si nous avons des labels de classe 0, 1, 2, 3, et 4, la couche de sortie du réseau de neurones devrait être constituée de cinq nœuds.

Dans PyTorch, les trois composants principaux d’une classe Dataset personnalisée sont le constructeur __init__, la méthode __getitem__, et la méthode __len__.

  • Dans la méthode __init__, nous configurons des attributs auxquels nous pourrons accéder plus tard dans les méthodes __getitem__ et __len__. Ceux-ci pourraient être des chemins de fichiers, des objets fichiers, des connecteurs de base de données, etc.

  • Dans la méthode __getitem__, nous définissons les instructions pour renvoyer exactement un élément du dataset via un indice. Cela fait référence aux caractéristiques et au label de classe correspondant à un seul exemple d’entraînement ou instance de test.

  • Enfin, la méthode __len__ contient les instructions pour récupérer la longueur du dataset. Ici, nous utilisons l’attribut .shape d’un tensor pour renvoyer le nombre de lignes dans le tableau de caractéristiques.

from torch.utils.data import Dataset, DataLoader

class ToyDataset(Dataset):
    def __init__(self, X, y):
        self.features = X
        self.labels = y
    
    # Instructions for retrieving exactly one data record and the corresponding label
    def __getitem__(self, index):
        one_x = self.features[index]
        one_y = self.labels[index]
        return one_x, one_y
    
    # Instructions for returning the length of the dataset
    def __len__(self):
        return self.labels.shape[0]
    

train_ds = ToyDataset(X_train, y_train)
test_ds = ToyDataset(X_test, y_test)
print(len(train_ds))
5
from torch.utils.data import DataLoader

torch.manual_seed(123)

# The ToyDataset insatance created earlier serves aas input in the data loader
train_loader = DataLoader(
    dataset=train_ds,
    batch_size=2,
    shuffle=True, # Wether or not to shuffle the data
    num_workers=0 # The number of background processes
)

test_loader = DataLoader(
    dataset=test_ds,
    batch_size=2,
    shuffle=False, # It is not necessary to shuffle a test dataset
    num_workers=0
)

shuffle=True sert à mélanger l’ordre des exemples à chaque époque d’entraînement. Son intérêt est surtout d’empêcher le modèle d’apprendre un biais lié à l’ordre des données. En pratique, ça aide l’optimisation avec SGD/minibatch, rend l’apprentissage plus stable, et améliore souvent la généralisation.

On l’utilise surtout pendant le training parce que le modèle doit voir les exemples dans un ordre différent à chaque passage. Si les données sont ordonnées par classe, difficulté, temps, ou source, ne pas mélanger peut rendre l’apprentissage moins bon.

On ne l’utilise généralement pas pendant le test ou la validation, parce qu’on veut une évaluation déterministe et reproductible. L’ordre des exemples ne doit pas influencer la mesure de performance. Donc on garde shuffle=False pour tester, valider, et comparer les résultats de façon stable.

Cas où il faut faire attention: si tes données sont séquentielles ou temporelles, comme une série temporelle ou certains problèmes de langage, il peut être incorrect de mélanger certains éléments. Dans ce cas, on choisit un autre type de découpage ou de batching.

En résumé:

  • training: souvent shuffle=True
  • validation/test: presque toujours shuffle=False
  • exception: données séquentielles ou dépendantes de l’ordre
for idx, (x, y) in enumerate(train_loader):
    print(f"Batch {idx + 1}: ", x, y)
Batch 1:  tensor([[ 2.3000, -1.1000],
        [-0.9000,  2.9000]]) tensor([1, 0])
Batch 2:  tensor([[-1.2000,  3.1000],
        [-0.5000,  2.6000]]) tensor([0, 0])
Batch 3:  tensor([[ 2.7000, -1.5000]]) tensor([1])

Comme on peut le voir à partir de la sortie précédente, train_loader parcourt le dataset d’entraînement en visitant chaque exemple une seule fois. C’est ce qu’on appelle une époque d’entraînement.

Comme nous avons initialisé le générateur aléatoire avec torch.manual_seed(123) ici, vous devriez obtenir exactement le même ordre de mélange des exemples d’entraînement. En revanche, si vous parcourez le dataset une deuxième fois, vous verrez que l’ordre du mélange change.

C’est voulu, afin d’éviter que les réseaux de neurones profonds ne se retrouvent bloqués dans des cycles de mise à jour répétitifs pendant l’entraînement.

Nous avons spécifié une taille de batch de 2 ici, mais le troisième batch ne contient qu’un seul exemple. C’est parce que nous avons cinq exemples d’entraînement, et 5 n’est pas divisible de manière égale par 2.

En pratique, avoir un batch considérablement plus petit comme dernier batch dans une epoch d’entraînement peut perturber la convergence pendant l’entraînement. Pour éviter cela, définissez drop_last=True, ce qui supprimera le dernier batch de chaque epoch, comme montré dans le listing suivant.

train_loader = DataLoader(
    dataset=train_ds,
    batch_size=2,
    shuffle=True,
    num_workers=0,
    drop_last=True
)
for idx, (x, y) in enumerate(train_loader):
    print(f"Batch {idx}:", x, y)
Batch 0: tensor([[-1.2000,  3.1000],
        [-0.5000,  2.6000]]) tensor([0, 0])
Batch 1: tensor([[ 2.3000, -1.1000],
        [-0.9000,  2.9000]]) tensor([1, 0])

L’apprentissage profond (deep learning) repose sur un mariage subtil entre des abstractions logicielles de haut niveau et une compréhension fine du matériel informatique sous-jacent. Lorsqu’un ingénieur écrit DataLoader(dataset, num_workers=4, batch_size=32), il mobilise en réalité un écosystème complet mettant en jeu des processus systèmes, des mécanismes de communication inter-processus, des hiérarchies mémoire, et des unités de calcul aux architectures radicalement différentes.

En tant que débutant, il est bien normal de se demander : comment les données transitent-elles, du stockage disque jusqu’aux cœurs de calcul d’un GPU, et comment ce pipeline peut-il être optimisé lorsque l’on dispose de plusieurs unités de traitement ?

C’est ce à quoi nous tenterons de répondre dans les lignes à suivre.


Partie I — Le DataLoader et les Workers : anatomie d’un pipeline de données

La boucle d’entraînement et son goulot d’étranglement fondamental

Un réseau de neurones s’entraîne par itérations successives. À chaque itération, le modèle reçoit un batch de données, calcule une prédiction (forward pass), mesure son erreur via une fonction de perte (loss), puis rétropropage cette erreur à travers ses couches pour ajuster ses paramètres (backward pass). Cette séquence se répète des milliers, voire des millions de fois.

Or, cette boucle possède une structure temporelle asymétrique : le GPU, lorsqu’il effectue un forward et un backward pass, travaille à une vitesse considérable — de l’ordre de quelques millisecondes pour un batch sur du matériel moderne. En revanche, charger ce batch depuis le disque, le décoder, lui appliquer des transformations, et l’assembler en tenseur peut prendre un temps comparable, voire supérieur. Si ces deux phases sont exécutées séquentiellement — c’est-à-dire si le GPU attend que les données soient prêtes avant de commencer à calculer — alors le GPU est en réalité inactif la moitié du temps. C’est ce qu’on appelle un I/O bottleneck (goulot d’étranglement sur les entrées/sorties). Pour un équipement qui coûte plusieurs milliers d’euros, ce gaspillage est inacceptable.

La solution conceptuelle est celle du pipeline : préparer les prochains batches pendant que le GPU travaille sur le batch courant. C’est exactement ce que réalise le mécanisme des workers.

Architecture interne du DataLoader

Le DataLoader de PyTorch n’est pas un simple itérateur. C’est un orchestrateur qui coordonne plusieurs composants distincts, chacun ayant une responsabilité précise.

Le Dataset est la couche d’abstraction qui représente les données. Il expose une interface minimale : __len__() pour connaître le nombre d’éléments, et __getitem__(i) pour accéder à l’élément d’indice i. C’est dans cette méthode que réside toute la logique de chargement : ouverture de fichier, décodage d’image, lecture depuis une base de données, etc. Le Dataset est volontairement ignorant de la notion de batch — il travaille sur des échantillons individuels.

Le Sampler est responsable de définir l’ordre dans lequel les indices seront parcourus. Le SequentialSampler les parcourt dans l’ordre naturel (0, 1, 2, …, N-1). Le RandomSampler, activé par shuffle=True, génère une permutation aléatoire de ces indices à chaque nouvelle epoch. Cette randomisation est cruciale pour l’entraînement : elle empêche le modèle de mémoriser l’ordre des données et améliore la généralisation.

Le BatchSampler est une couche supérieure au-dessus du Sampler. Il regroupe les indices générés par le Sampler en sous-listes de taille batch_size. Si drop_last=True, le dernier groupe est ignoré lorsqu’il contient moins de batch_size éléments.

La collate_fn est la fonction qui, étant donné une liste d’échantillons individuels, les assemble en un batch de tensors. Par défaut, PyTorch fournit une collate_fn qui empile les tensors individuels le long d’un nouvel axe de dimension zéro. Il est possible de fournir une fonction personnalisée pour gérer des structures de données complexes (dictionnaires, objets à taille variable, etc.).

Les workers sont les processus fils chargés d’exécuter dataset[i] et d’appliquer les transformations, en parallèle du processus principal.

Le mode num_workers=0 : tout dans le processus principal

Lorsque num_workers=0 (valeur par défaut), il n’y a aucun processus fils. Le processus principal est seul, et il effectue toutes les opérations séquentiellement :

[Main Process]
  1. Demander les indices au BatchSampler → [3, 1]
  2. Appeler dataset[3] → charger et décoder l'échantillon 3
  3. Appeler dataset[1] → charger et décoder l'échantillon 1
  4. Appeler collate_fn([échantillon_3, échantillon_1]) → batch
  5. Transférer le batch vers le GPU
  6. Exécuter le forward pass
  7. Exécuter le backward pass
  8. Mettre à jour les weights
  9. Retourner en 1.

Dans ce mode, le GPU est inactif aux étapes 1 à 5. Il attend que le CPU ait fini de préparer les données.

Ce mode est cependant parfaitement adapté aux datasets de petite taille entièrement chargés en RAM (comme dans notre exemple jouet avec 5 tensors), car le coût de chargement est négligeable, et les workers introduiraient un surcoût de démarrage inutile.

Le mode num_workers > 0 : parallélisme par processus

Dès que num_workers=N avec N > 0, PyTorch crée N processus fils au démarrage de l’itération. Ces processus sont créés via le mécanisme de fork (ou spawn sur Windows et macOS selon la configuration), qui est un appel système dupliquant l’espace mémoire du processus parent.

Le flux de contrôle devient alors le suivant :

[Main Process]
  ├── Crée N workers au démarrage
  ├── Génère les groupes d’indices via le BatchSampler
  ├── Place ces groupes dans l’Index Queue
  ├── Attend qu’un batch apparaisse dans la Result Queue
  └── Dès qu’un batch arrive → GPU forward/backward → mise à jour

[Worker 1]
  ├── Attend qu’un groupe d’indices soit disponible
  ├── Récupère par exemple [0,1,2,3] depuis l’Index Queue
  ├── Charge dataset[0], dataset[1], ...
  ├── Applique les transforms
  ├── Construit le batch avec collate_fn
  └── Dépose le batch dans la Result Queue

[Worker 2]
  ├── Récupère un autre groupe d’indices
  └── Fait le même travail en parallèle

Les workers communiquent avec le processus principal grâce à deux files partagées :

  • Index Queue : contient les groupes d’indices à traiter ;
  • Result Queue : contient les batches déjà préparés.

Ces files sont des objets multiprocessing.Queue, spécialement conçus pour permettre à plusieurs processus indépendants de communiquer de manière sûre.

Le point important est que plusieurs workers peuvent accéder à la même queue sans se gêner. Lorsqu’un worker exécute :

indices = index_queue.get()

l’opération est automatiquement protégée par un mécanisme interne de synchronisation (lock). Cela garantit qu’un seul worker peut retirer un élément donné.

Par exemple, si la queue contient :

[[0,1], [2,3], [4,5], [6,7]]

et que deux workers demandent un travail au même moment :

  • Worker 1 reçoit [0,1]
  • Worker 2 reçoit [2,3]

Une fois qu’un groupe d’indices est retiré, il disparaît immédiatement de la queue. Aucun autre worker ne peut donc recevoir le même groupe.

Après avoir récupéré ses indices, chaque worker :

  1. charge les exemples correspondants avec dataset[i],
  2. applique les éventuelles transformations,
  3. regroupe les données avec collate_fn,
  4. puis place le batch final dans la Result Queue.

Pendant ce temps, le processus principal continue l’entraînement sur le GPU. Dès qu’il termine un batch, il récupère immédiatement le suivant dans la Result Queue.

Pour que ces échanges soient rapides, PyTorch utilise la shared memory. Sans cela, chaque batch devrait être entièrement copié d’un processus à un autre, ce qui coûterait beaucoup de temps et de mémoire.

À la place :

  • les tensors sont placés dans une zone mémoire accessible par tous les processus ;
  • la queue transmet surtout une référence vers cette zone shared memory.

Ainsi, les données volumineuses ne sont pas recopiées inutilement entre workers et processus principal.

Le paramètre prefetch_factor contrôle combien de batches chaque worker prépare à l’avance.

Par exemple :

num_workers = 4
prefetch_factor = 2

signifie que :

  • chaque worker peut préparer jusqu’à 2 batches en attente ;
  • donc jusqu’à 4 × 2 = 8 batches peuvent être prêts ou en cours de préparation simultanément.

L’idée est que le GPU ne doive presque jamais attendre les données : pendant qu’il entraîne le modèle sur un batch, les workers préparent déjà les suivants en arrière-plan.

Loading data without multiple workers (setting num_workers=0) will create a data loading bottleneck where the model sits idle until the next batch is loaded (left). If multiple workers are enabled, the data loader can queue up the next batch in the background (right).

Le mécanisme de pin_memory

Jusqu’ici, l’optimisation concernait principalement la communication entre les différents processus CPU. Grâce à la shared memory, les workers peuvent transmettre efficacement les batches préparés au processus principal sans recopier inutilement les tensors en mémoire.

Cependant, une fois ce batch récupéré par le processus principal, une seconde étape critique commence : le transfert des données depuis la RAM vers la mémoire de la carte graphique (VRAM). Or, cette communication entre le CPU et le GPU possède elle aussi ses propres contraintes matérielles et peut rapidement devenir un goulot d’étranglement.

Autrement dit, le pipeline complet ressemble désormais à ceci :

Workers CPU ──(shared memory)──> Main Process CPU ──(?)──> GPU

La shared memory optimise donc uniquement la première liaison — celle entre les processus CPU. Mais elle n’accélère pas directement le transfert des données vers le GPU. C’est précisément pour optimiser cette seconde liaison qu’intervient le mécanisme de pinned memory, activé via pin_memory=True.

Pour bien comprendre son utilité, il faut d’abord distinguer les deux grandes mémoires d’un ordinateur :

  • le disque dur (ou SSD), très vaste mais relativement lent ;
  • la mémoire vive (RAM), beaucoup plus rapide mais de capacité plus limitée.

Lorsque la RAM commence à saturer, le système d’exploitation utilise un mécanisme appelé mémoire virtuelle (virtual memory ou swapping). Certaines données temporairement peu utilisées peuvent alors être déplacées depuis la RAM vers le disque dur afin de libérer de l’espace mémoire.

Pour les applications classiques, ce mécanisme est extrêmement pratique et totalement transparent. Mais dans le cadre de l’entraînement d’un réseau de neurones sur GPU, cette flexibilité devient problématique.

Pendant l’entraînement :

  1. le CPU prépare les batches dans la RAM ;
  2. puis le GPU doit récupérer ces données pour effectuer les calculs.

Or, le GPU fonctionne comme un périphérique extrêmement rapide qui a besoin que les données restent physiquement stables en mémoire pendant leur transfert. Si le système d’exploitation déplaçait soudainement une partie du batch vers le disque dur au mauvais moment, le transfert deviendrait beaucoup plus lent.

C’est précisément le rôle de pin_memory=True.

Lorsque cette option est activée, PyTorch demande au système d’exploitation de placer les batches dans une zone spéciale de la RAM appelée pinned memory (page-locked memory). Cette mémoire est « verrouillée » :

  • le système d’exploitation n’a plus le droit de déplacer ces données vers le disque ;
  • les données restent fixes physiquement en RAM pendant le transfert.

Le GPU peut alors accéder directement à cette mémoire grâce à un mécanisme matériel appelé DMA (Direct Memory Access).

Le transfert devient donc :

Pinned RAM ──(DMA rapide)──> VRAM GPU

L’avantage est double :

  1. le transfert CPU → GPU devient beaucoup plus rapide ;
  2. le CPU n’a plus besoin de superviser constamment cette copie et peut déjà commencer à préparer le batch suivant.

Le pipeline devient alors beaucoup plus fluide :

[Workers CPU]

      ├── Préparent les batches

      ├── Shared Memory


[Main Process CPU]

      ├── pin_memory : verrouille le batch en RAM


[GPU]
      ├── Récupère les données via DMA
      └── Lance l’entraînement

Sans pin_memory=True, le transfert est moins efficace. Comme les données résident dans une RAM « normale » (pageable memory), le système doit souvent effectuer une étape intermédiaire supplémentaire :

  1. copier les données vers une zone temporairement verrouillée ;
  2. puis seulement lancer le transfert vers le GPU.

Cette copie additionnelle monopolise inutilement le CPU et ralentit l’ensemble du pipeline d’entraînement.

Ainsi :

  • la shared memory optimise les échanges entre processus CPU ;
  • la pinned memory optimise les transferts entre la RAM et le GPU.

Ces deux mécanismes interviennent donc à des étapes différentes mais complémentaires du pipeline de chargement des données.


Partie II — Entraînement Multi-GPU : stratégies et mécanismes


Pourquoi plusieurs GPUs ?

Pour comprendre l’utilité de plusieurs GPUs, il faut d’abord saisir deux limites fondamentales et bien distinctes que l’on rencontre lors de l’entraînement de modèles modernes.

La première limite est une limite physique de stockage. Chaque GPU dispose d’une mémoire vidéo (VRAM) dont la capacité est fixe et non extensible — typiquement entre 16 Go et 80 Go selon les modèles haut de gamme. Or, un grand modèle de langage comme GPT-3 pèse à lui seul 350 Go en précision float16. Il est donc tout simplement impossible, au sens physique du terme, de loger l’intégralité de ses paramètres dans un seul GPU. Peu importe la rapidité du matériel, le problème n’est pas de vitesse mais de capacité d’accueil : on ne peut pas faire tenir un meuble de cinq mètres dans une pièce de deux mètres. Il faut alors distribuer le modèle lui-même sur plusieurs GPUs, chacun n’en hébergeant qu’une fraction.

La seconde limite est une limite de temps. Même lorsque le modèle tient confortablement dans un seul GPU, l’entraînement peut durer des semaines ou des mois. Ici, l’enjeu n’est plus la capacité mais la vitesse. Si l’on dispose de N GPUs capables de traiter des données en parallèle, on peut théoriquement diviser le temps d’entraînement par N en traitant N fois plus d’exemples à chaque instant.

Ces deux motivations — l’une contraignante, l’autre optimisante — donnent naissance à deux grandes familles de stratégies que nous allons explorer : le parallélisme de données (Data Parallelism), où le modèle est dupliqué et les données sont réparties ; et le parallélisme de modèle (Model Parallelism), où c’est le modèle lui-même qui est fractionné entre les GPUs.


DataParallel (DP) : la première approche, naïve

torch.nn.DataParallel est l’API historique de PyTorch pour l’entraînement multi-GPU. Pour bien comprendre ses limites, il faut d’abord comprendre comment elle fonctionne de l’intérieur — et cela commence par deux notions fondamentales : ce qu’est un processus et ce qu’est un thread.


Processus et threads : poser les bases.

Lorsque vous lancez un programme Python, le système d’exploitation crée un processus. Ce processus est une unité d’exécution totalement autonome et isolée : il possède son propre espace mémoire, ses propres ressources, sa propre identité. Deux processus ne partagent rien par défaut. Si l’un plante, l’autre continue de tourner sans en être affecté.

Un thread est quelque chose de plus fin. C’est un fil d’exécution secondaire qui vit à l’intérieur d’un même processus. Plusieurs threads coexistent dans le même processus, partagent le même espace mémoire et les mêmes ressources, mais avancent chacun dans le code à leur propre rythme.

L’analogie la plus parlante est celle d’un bureau et de ses employés. Le processus est le bureau : il a ses propres murs, ses propres dossiers, son propre matériel. Les threads sont les employés qui travaillent dans ce bureau. Ils ont tous accès aux mêmes armoires, aux mêmes fichiers, aux mêmes outils — c’est précisément ce partage qui leur permet de collaborer rapidement, sans avoir à s’échanger des copies de documents. Mais ce même partage crée un risque : si deux employés modifient le même fichier en même temps, le résultat sera corrompu.

C’est exactement pour éviter cette corruption que Python a introduit le GIL (Global Interpreter Lock). Imaginez qu’il n’existe qu’un seul stylo dans le bureau, et qu’un employé doit l’avoir en main pour avoir le droit d’agir. Les autres peuvent attendre, observer, se préparer — mais ils ne peuvent rien exécuter tant que le stylo n’est pas libre. Conséquence directe et non intuitive : même si votre machine possède 8 cœurs physiques capables d’exécuter 8 fils d’exécution simultanément, Python ne laissera jamais deux threads s’exécuter vraiment en même temps. L’un tient le stylo, les autres attendent leur tour. Le parallélisme apparent est donc une illusion : on a l’impression d’une exécution simultanée, mais les threads se relaient en réalité en alternance très rapide.


Le fonctionnement de DataParallel.

DataParallel s’appuie précisément sur ce modèle de threads. Pour comprendre pourquoi c’est problématique, suivons le déroulement d’une itération d’entraînement.

Le processus principal — un bureau unique avec un seul chef — prend en charge l’ensemble du batch depuis le DataLoader. Il le découpe en portions égales et en confie une à chacun des GPUs disponibles. Chaque GPU effectue alors, en parallèle, un forward pass indépendant sur sa portion. Jusqu’ici, l’organisation semble efficace.

Mais voici où le premier problème se révèle : une fois les calculs terminés sur chaque GPU, toutes les sorties sont renvoyées au GPU 0 pour qu’il les rassemble, calcule la loss globale, effectue le backward pass, puis redistribue les gradients et resynchronise les poids de tous les GPUs. Autrement dit, la phase de travail parallèle est éphémère — le reste de l’itération est entièrement séquentiel et concentré sur une seule machine.

Ce modèle crée un goulot d’étranglement structurel sur le GPU 0. Il est le seul à recevoir le batch complet, à assembler les résultats, à calculer les gradients globaux, et à redistribuer les poids. Il est donc, en permanence, bien plus sollicité que ses homologues. En pratique, au-delà de 2 à 4 GPUs, les GPUs supplémentaires n’apportent presque aucun gain réel : ils passent la majorité de leur temps à attendre que le GPU 0 ait terminé.


Le second défaut : le GIL paralyse la coordination.

Un second problème, moins visible mais tout aussi pénalisant, découle directement de l’architecture à threads décrite plus haut. Dans DataParallel, les différents GPUs sont pilotés par des threads Python distincts, tous logés dans le même processus. L’intention est louable : faire avancer plusieurs GPUs en parallèle. Mais dès que ces threads ont besoin d’exécuter du code Python pur — interpréter une boucle, évaluer une condition, appeler une fonction personnalisée dans le forward pass — ils se retrouvent à faire la queue devant le GIL.

Pendant que le thread du GPU 0 tient le stylo, le thread du GPU 1 est suspendu, quand bien même son GPU est matériellement disponible et impatient de travailler. Plus le forward pass du modèle contient de logique Python complexe, plus cette file d’attente s’allonge — et plus le bénéfice du multi-GPU s’évapore. La coordination entre threads, au lieu d’accélérer les choses, finit par les freiner.

Ces deux défauts — la centralisation sur le GPU 0 et le GIL — ne sont pas des bugs corrigeables par un patch : ils sont la conséquence directe du choix architectural fondateur de DataParallel, celui d’un processus unique pilotant tout. C’est ce choix qu’il fallait remettre en cause, et c’est exactement ce que fait DDP.


DistributedDataParallel (DDP) : le standard actuel

Le transfert du modèle et des données dans DDP implique deux étapes clés. Tout d’abord, nous créons une copie du modèle sur chacun des GPUs. Ensuite, nous divisons les données d’entrée en minibatches uniques que nous transmettons à chaque copie du modèle.


Les forward pass et backward pass dans DDP sont exécutés indépendamment sur chaque GPU avec son sous-ensemble de données correspondant. Une fois les forward pass et backward pass terminés, les gradients de chaque réplique du modèle (sur chaque GPU) sont synchronisés sur l’ensemble des GPUs. Cela garantit que chaque réplique du modèle possède les mêmes poids mis à jour.


Pour comprendre pourquoi DistributedDataParallel (DDP) représente une rupture architecturale et non une simple amélioration, il faut revenir sur la source du problème précédent : la centralisation. DataParallel avait un chef unique. DDP supprime entièrement cette hiérarchie.

De la cuisine centralisée à la brigade décentralisée.

Avec DDP, chaque GPU est géré par son propre processus Python indépendant, lancé via torchrun. Il n’existe plus de processus maître vers lequel tout converge. Chaque processus charge ses propres données, calcule ses propres gradients, et prend ses propres décisions — tout en restant parfaitement synchronisé avec ses pairs. Cette indépendance des processus résout immédiatement le problème du GIL : puisqu’il s’agit désormais de processus distincts.

Chaque processus charge ses propres données.

Pour éviter que tous les GPUs ne traitent les mêmes exemples — ce qui serait un gaspillage total — PyTorch fournit un DistributedSampler. Son rôle est de partitionner le dataset en sous-ensembles disjoints, un par processus. Si l’on dispose de N processus et M exemples au total, le processus numéro k se voit attribuer les exemples d’indices k, k+N, k+2N, etc. L’ensemble des processus couvre ainsi exactement l’intégralité du dataset, sans redondance ni omission.

La synchronisation des gradients par AllReduce.

À l’issue du backward pass, chaque processus a calculé ses propres gradients — exacts pour son mini-batch, mais partiels au regard de l’ensemble des données. Pour que tous les modèles évoluent de façon identique et convergent vers la même solution, il est impératif que chaque GPU applique la même mise à jour, c’est-à-dire la moyenne des gradients calculés sur l’ensemble des processus.

C’est précisément la fonction de l’opération AllReduce. Elle garantit que, quel que soit le GPU interrogé, chacun reçoit la somme (ou la moyenne) complète de tous les gradients calculés par ses pairs. Cette opération est implémentée par NCCL (NVIDIA Collective Communications Library), qui exploite les connexions physiques directes entre GPUs : NVLink pour les GPUs sur la même machine, InfiniBand ou Ethernet pour les GPUs répartis sur des serveurs distants.

L’algorithme Ring-AllReduce : pourquoi un anneau ?

L’implémentation naïve d’un AllReduce consisterait à envoyer tous les gradients vers un nœud central qui les somme puis les redistribue. On retomberait alors dans le goulot d’étranglement de DataParallel. NCCL évite ce piège grâce au Ring-AllReduce, dont l’élégance mérite une explication.

Voici une explication reformulée, construite directement depuis le contenu du papier.


Le Ring-AllReduce : comment N GPUs s’échangent leurs gradients sans goulot d’étranglement

Dans l’entraînement distribué en données parallèles, chaque GPU calcule ses propres gradients sur son sous-ensemble du batch. Il faut ensuite les moyenner entre tous les GPUs avant de mettre à jour les poids. Mais le défaut est immédiat : le GPU central doit recevoir les gradients de tous les autres, puis les renvoyer à tous. Sa charge de communication croît linéairement avec le nombre de GPUs. Avec un modèle de 300 millions de paramètres (soit 1,2 Go de gradients) et 10 GPUs, chaque itération se ralentit de plus de 10 secondes. La solution ne passe pas à l’échelle.

Le Ring-AllReduce élimine ce goulot en supprimant le réducteur central. Voici comment il fonctionne.

La topologie. On dispose les N GPUs en anneau logique. Chaque GPU a exactement un voisin à sa gauche et un à sa droite. Il n’envoie des données qu’à droite, il n’en reçoit que depuis la gauche.

La première phase : le Scatter-Reduce. Chaque GPU découpe son tableau de gradients en N fragments de taille égale. Puis on fait N-1 tours dans l’anneau. À chaque tour, chaque GPU envoie un fragment à son voisin de droite et reçoit un fragment de son voisin de gauche — qu’il additionne à son propre fragment correspondant. Le fragment envoyé à chaque tour est toujours celui reçu au tour précédent. Au bout de N-1 tours, chaque GPU détient un fragment qui contient la somme complète de ce fragment, agrégée sur l’ensemble des GPUs. Pas l’intégralité des gradients — juste sa portion à lui, mais parfaitement réduite.

La deuxième phase : l’Allgather. On refait N-1 tours dans l’anneau, mais cette fois chaque GPU, au lieu d’additionner ce qu’il reçoit, écrase simplement le fragment correspondant avec la valeur reçue. Au bout de N-1 tours, chaque GPU a reçu successivement tous les fragments réduits et possède donc l’intégralité des gradients agrégés.

Pourquoi la bande passante est indépendante de N. À chaque tour, chaque GPU envoie et reçoit un fragment de taille K/N (où K est la taille totale du tableau). Sur les 2(N-1) tours au total (N-1 par phase), la quantité totale de données transférée par chaque GPU est donc 2(N-1) × K/N, ce qui tend vers 2K quand N est grand — et surtout ne dépend pas de N. Peu importe qu’on ait 4 ou 400 GPUs dans l’anneau, chaque lien transporte la même quantité de données. C’est la propriété fondamentale qui rend l’algorithme scalable : ajouter des GPUs n’aggrave pas la communication.

L’optimisation supplémentaire. Puisque la backpropagation calcule les gradients depuis la dernière couche vers la première, les gradients des couches de sortie sont disponibles bien avant ceux des couches d’entrée. On peut donc démarrer le Ring-AllReduce sur les premiers gradients disponibles pendant que les autres sont encore en cours de calcul, chevauchant communication et calcul. Dans les expériences du papier sur un modèle de 300 millions de paramètres, cela permettait d’économiser 70 à 120 ms par itération.

Pour plus de détails, vous pouvez consulter [Bringing HPC Techniques to Deep Learning](../Introduction to PyTorch/baidu_allreduce_oct6.pdf).


Parallélisme de modèle (Model Parallelism)

Aussi efficace que soit DDP, il suppose implicitement une chose : que le modèle tient dans la VRAM d’un seul GPU. Lorsque ce n’est plus le cas — ce qui est la norme pour les très grands modèles — une toute autre famille de stratégies s’impose, dans laquelle ce n’est plus le dataset qui est distribué, mais le modèle lui-même.

Le pipeline parallelism : la chaîne de montage.

La forme la plus intuitive de parallélisme de modèle consiste à assigner des groupes de couches à des GPUs différents. Le GPU 0 héberge les premières couches, le GPU 1 les suivantes, et ainsi de suite. Lors d’un forward pass, les activations sont calculées sur le GPU 0, transmises au GPU 1 pour la suite du calcul, puis au GPU 2, etc. L’analogie avec une chaîne de montage industrielle est directe : chaque station effectue une opération précise et passe le résultat à la suivante.

Mais cette analogie révèle aussi immédiatement le défaut majeur de l’approche naïve. Sur une vraie chaîne de montage où une seule pièce circule à la fois, toutes les stations sont inactives sauf une. C’est exactement ce qui se passe ici : à chaque instant, un seul GPU travaille pendant que tous les autres attendent. L’utilisation effective est de 1/N — catastrophique.

La solution consiste à ne plus faire circuler un seul batch, mais à le découper en micro-batchs injectés en rafale dans le pipeline. Dès que le GPU 0 a traité le micro-batch 1 et l’a transmis au GPU 1, il n’attend pas le résultat final : il attaque immédiatement le micro-batch 2. Pendant ce temps, le GPU 1 reçoit le micro-batch 1 et commence à le traiter. Le GPU 2 fait de même un cycle plus tard. Les premiers cycles servent à remplir le pipeline — c’est inévitable, comme le début de toute chaîne de montage. Mais une fois ce remplissage terminé, tous les GPUs travaillent en permanence, chacun sur un micro-batch différent au même instant. Cette technique, introduite par GPipe et PipeDream, a rendu l’entraînement des très grands modèles économiquement viable.

Le tensor parallelism : découper les matrices elles-mêmes.

Le pipeline parallelism distribue les couches entre les GPUs, mais que faire lorsqu’une seule couche — une matrice de projection dans un mécanisme d’attention, par exemple — est à elle seule trop volumineuse pour un GPU ?

Le tensor parallelism répond à cette question en distribuant les matrices de poids elles-mêmes entre GPUs. Concrètement, une grande matrice peut être découpée soit par colonnes, soit par lignes. Chaque GPU ne stocke et ne calcule qu’un fragment de cette matrice, produisant un résultat partiel. Ces résultats partiels sont ensuite agrégés via AllReduce pour reconstituer la sortie complète. Cette approche, popularisée par Megatron-LM (voir l’article Megatron-LM-Training Multi-Billion Parameter Language Models Using) de NVIDIA, opère donc au sein d’une même couche, là où le pipeline parallelism opérait entre couches.

Le 3D Parallelism : la synthèse des trois stratégies.

En pratique, les modèles d’envergure comme GPT-4 ou LLaMA 3 ne choisissent pas entre ces stratégies : ils les combinent toutes trois simultanément, dans une organisation appelée 3D Parallelism (voir l’article Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM).

  • Le parallélisme de données (DDP) opère entre groupes de GPUs : plusieurs répliques du même sous-modèle traitent des mini-batchs différents et synchronisent leurs gradients.
  • Le parallélisme de pipeline opère entre étages de couches : les différents blocs du modèle sont répartis sur des GPUs distincts mis en cascade.
  • Le parallélisme de tenseurs opère au sein de chaque couche : les matrices sont fragmentées entre GPUs d’un même étage.

Chaque dimension de ce cube résout un problème différent : le tensor parallelism gère les couches trop larges, le pipeline parallelism gère les modèles trop profonds, et le data parallelism maximise le débit. Leur combinaison est ce qui rend techniquement possible l’entraînement de modèles comptant des centaines de milliards de paramètres sur des clusters de milliers de GPUs.


Autres ressources très intéressantes :

Une boucle d’entraînement typique

Entraînons maintenant le réseau de neurones sur le dataset jouet

import torch.nn.functional as F

torch.manual_seed(123)
model = NeuralNetwork(num_inputs=2, num_outputs=2) # The dataset has two features and two classes
optimizer = torch.optim.SGD(
    model.parameters(), lr=0.5 # The optimizer needs to know which parameters to optimize
)
num_epochs = 3
for epoch in range(num_epochs):
    model.train()
    for batch_idx, (features, labels) in enumerate(train_loader):
        # Forward pass
        logits = model(features)
        loss = F.cross_entropy(logits, labels)

        # Backward pass
        optimizer.zero_grad() # Sets the gradients from the previous round to 0 to prevent unintended gradient accumulation
        loss.backward() # Computes the gradients of the loss given the model parameters
        optimizer.step() # The optimizer uses the gradients to update the model parameters 

        ### LOGGING
        print(f"Epoch: {epoch+1:03d}/{num_epochs:03d}"
              f" | Batch {batch_idx:03d}/{len(train_loader):03d}"
              f" | Train Loss: {loss: .2f}")
        
    model.eval()
    # Insert optional model evaluation code 
Epoch: 001/003 | Batch 000/002 | Train Loss:  0.75
Epoch: 001/003 | Batch 001/002 | Train Loss:  0.65
Epoch: 002/003 | Batch 000/002 | Train Loss:  0.44
Epoch: 002/003 | Batch 001/002 | Train Loss:  0.13
Epoch: 003/003 | Batch 000/002 | Train Loss:  0.03
Epoch: 003/003 | Batch 001/002 | Train Loss:  0.00
# Exercise A.3 :
num_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print("Total number of trainable model parameters:", num_params)
Total number of trainable model parameters: 752

En pratique, nous utilisons souvent un troisième dataset, appelé validation dataset (jeu de validation), pour trouver les réglages optimaux des hyperparamètres.

Nous avons également introduit de nouveaux réglages appelés model.train() et model.eval(). Comme ces noms l’indiquent, ces réglages sont utilisés pour mettre le modèle dans un mode d’entraînement (training) et un mode d’évaluation (evaluation). Ceci est nécessaire pour les composants qui se comportent différemment pendant l’entraînement et l’inférence, tels que les couches de dropout ou de batch normalization.

Étant donné que nous n’avons pas de dropout ou d’autres composants dans notre classe NeuralNetwork qui sont affectés par ces réglages, l’utilisation de model.train() et model.eval() est redondante dans notre code précédent. Cependant, c’est une bonne pratique de les inclure de toute façon pour éviter des comportements inattendus lorsque nous modifions l’architecture du modèle ou réutilisons le code pour entraîner un modèle différent.

Comme discuté précédemment, nous passons les logits directement à la fonction de loss cross_entropy, qui appliquera la fonction softmax en interne pour des raisons d’efficacité et de stabilité numérique.

Ensuite, l’appel à loss.backward() calculera les gradients dans le graphe de calcul que PyTorch a construit en arrière-plan. La méthode optimizer.step() utilisera les gradients pour mettre à jour les paramètres du modèle afin de minimiser la loss.

Dans le cas de l’optimizer SGD, cela signifie multiplier les gradients par le learning rate et ajouter le gradient négatif mis à l’échelle aux paramètres.


Pourquoi passer les Logits directement à la fonction Cross-Entropy ?

En PyTorch, on passe généralement les logits (sorties brutes) directement à la fonction de perte cross_entropy, sans appliquer le Softmax nous-mêmes, pour des raisons de stabilité numérique.

1. Le problème (L’instabilité numérique)

Si un modèle génère un logit très grand, le calcul manuel du Softmax (exe^x) provoque un dépassement de capacité en mémoire (overflow).

Exemple naïf :

import torch

logits = torch.tensor([1000.0, 2.0, -1.0])
exponentielles = torch.exp(logits) 
# Résultat : tensor([inf, 7.3891, 0.3679])  <-- L'ordinateur ne gère pas e^1000

probabilites = exponentielles / torch.sum(exponentielles)
# Résultat : tensor([nan,  0.,  0.])  <-- inf/inf crash le calcul (Not a Number)

2. La solution (Ce que fait PyTorch en coulisses)

PyTorch utilise l’astuce mathématique LogSumExp. Avant de calculer l’exponentielle, il soustrait la valeur maximale à tous les logits.

L’astuce en action :

# On soustrait le maximum (1000) à tous les logits
logits_stables = logits - torch.max(logits)
# Résultat (1000-1000, 2-1000, -1-1000) : tensor([0., -998., -1001.])

exponentielles_stables = torch.exp(logits_stables)
# Résultat : tensor([1.0, 0.0, 0.0])  <-- Fini les "inf" ! L'exponentielle de 0 est 1.

3. Pourquoi la soustraction donne-t-elle les mêmes probabilités ? (La Preuve)

On pourrait penser que modifier les logits fausse le résultat final. Or, la fonction Softmax est insensible à l’addition ou la soustraction d’une constante ! Voici la preuve mathématique :

La formule de la probabilité pour un logit xix_i est :
P(xi)=exiexP(x_i) = \frac{e^{x_i}}{\sum e^{x}}

Si l’on soustrait une constante CC (notre maximum) à tous les logits, selon la règle des puissances (eab=ea×ebe^{a-b} = e^a \times e^{-b}), on obtient :
Pnouveau(xi)=exiCexC=exi×eC(ex×eC)P_{nouveau}(x_i) = \frac{e^{x_i - C}}{\sum e^{x - C}} = \frac{e^{x_i} \times e^{-C}}{\sum \left( e^{x} \times e^{-C} \right)}

Puisque eCe^{-C} est présent dans chaque terme de la somme au dénominateur, on peut le factoriser :
Pnouveau(xi)=exi×eCeC×exP_{nouveau}(x_i) = \frac{e^{x_i} \times e^{-C}}{e^{-C} \times \sum e^{x}}

Le terme eCe^{-C} est présent en haut et en bas de la fraction, il s’annule :
Pnouveau(xi)=exiex=P(xi)P_{nouveau}(x_i) = \frac{e^{x_i}}{\sum e^{x}} = P(x_i)

Conclusion : Soustraire le maximum modifie les nombres intermédiaires pour éviter le crash mémoire, mais la division finale rétablit l’équilibre. En donnant directement les logits à la cross_entropy, PyTorch effectue cette optimisation complexe en un seul bloc ultra-rapide.

# After we have trained the model, we can use it to make predictions
model.eval()
with torch.no_grad():
    outputs = model(X_train)
print(outputs)
tensor([[ 2.8569, -4.1618],
        [ 2.5382, -3.7548],
        [ 2.0944, -3.1820],
        [-1.4814,  1.4816],
        [-1.7176,  1.7342]])

Pour obtenir les probabilités d’appartenance aux classes, nous pouvons ensuite utiliser la fonction softmax de PyTorch

torch.set_printoptions(sci_mode=False)
probas = torch.softmax(outputs, dim=1)
print(probas)
tensor([[0.9991, 0.0009],
        [0.9982, 0.0018],
        [0.9949, 0.0051],
        [0.0491, 0.9509],
        [0.0307, 0.9693]])

L’appel à set_printoptions est utilisé ici pour rendre l’affichage (la sortie) plus lisible.

Par défaut, si les nombres dans tes tenseurs sont très petits ou très grands, PyTorch va utiliser la notation scientifique (par exemple 1.234e-05 au lieu de 0.000012).

# We can convert these values into class label predictions
predictions = torch.argmax(probas, dim=1)
print(predictions)
tensor([0, 0, 0, 1, 1])

Notez qu’il n’est pas nécessaire de calculer les probabilités softmax pour obtenir les class labels (étiquettes de classe). Nous pourrions également appliquer la fonction argmax directement aux logits.

predictions = torch.argmax(outputs, dim=1)
print(predictions)
tensor([0, 0, 0, 1, 1])

Étant donné que le training dataset (jeu d’entraînement) est relativement petit, nous pourrions comparer à l’œil nu les labels prédits aux vrais labels.

predictions == y_train
tensor([True, True, True, True, True])

En utilisant torch.sum, nous pouvons compter le nombre de prédictions correctes.

torch.sum(predictions == y_train)
tensor(5)

Pour généraliser le calcul de l’accuracy de prédiction, implémentons la fonction compute_accuracy

def compute_accuracy(model, dataloader):

    model.eval()
    correct = 0.0
    total_examples = 0

    for idx, (features, labels) in enumerate(dataloader):

        with torch.no_grad():
            logits = model(features)

        predictions = torch.argmax(logits, dim=1)
        # Returns a tensor of True/False values depending on whether the labels match
        compare = labels == predictions
        # The sum operations counts the number of True values
        correct += torch.sum(compare)
        total_examples += len(compare)

    # The fraction of correct prediction, a value between 0 and 1.
    # item() returns the value of the tensor as Python float.
    return (correct / total_examples).item()
# We can then apply the function to the training 
print(compute_accuracy(model, train_loader))
1.0
# Similary, we can apply the function to the test set
print(compute_accuracy(model, test_loader))
1.0

Sauvegarde et chargement des modèles

# To save models in Pytorch
torch.save(model.state_dict(), "model.pth")

Le state_dict du modèle est un objet dictionnaire Python qui associe chaque couche du modèle à ses paramètres entraînables (weights et biases).

model.pth est un nom de fichier arbitraire pour le fichier du modèle enregistré sur le disque. Nous pouvons lui donner n’importe quel nom et extension de fichier ; cependant, .pth et .pt sont les conventions les plus courantes.

# Une fois le modèle sauvegardé, on peut le recharger depuis le disque
model =  NeuralNetwork(2, 2)
model.load_state_dict(torch.load("model.pth"))
<All keys matched successfully>

La fonction torch.load() lit le fichier model.pth et reconstruit l’objet dictionnaire Python contenant les paramètres du modèle, tandis que model.load_state_dict() applique ces paramètres au modèle, restaurant ainsi efficacement son état appris à partir du moment où nous l’avons sauvegardé.


La ligne model = NeuralNetwork(2, 2) a été incluse ici pour illustrer que nous avons besoin d’une instance du modèle en mémoire pour appliquer les paramètres sauvegardés.

Optimisation des performances d’entraînement avec des GPUs

Calculs PyTorch sur les devices GPU

Dans PyTorch, un device est l’endroit où les calculs ont lieu et où les données résident. Le CPU et le GPU sont des exemples de devices. Un tensor PyTorch réside dans un device, et ses opérations sont exécutées sur le même device.

# We can double-check that our runtime indeed supports GPU computing
print(torch.cuda.is_available())
True

Maintenant, supposons que nous ayons deux tensors que nous pouvons additionner ; ce calcul sera effectué sur le CPU par défaut

tensor_1 = torch.tensor([1., 2., 3.])
tensor_2 = torch.tensor([4., 5., 6.])
print(tensor_1 + tensor_2)
tensor([5., 7., 9.])

Nous pouvons maintenant utiliser la méthode .to(). C’est la même méthode que celle employée pour changer le type de données d’un tenseur, mais cette fois-ci pour transférer ces tenseurs sur un GPU et y effectuer l’addition.

tensor_1 = tensor_1.to("cuda")
tensor_2 = tensor_2.to("cuda")
print(tensor_1 + tensor_2)
tensor([5., 7., 9.], device='cuda:0')

Le tensor résultant inclut désormais l’information du device, device='cuda:0', ce qui signifie que les tensors résident sur le premier GPU. Si votre machine héberge plusieurs GPUs, vous pouvez spécifier vers quel GPU vous souhaitez transférer les tensors. Vous faites cela en indiquant l’ID du device dans la commande de transfert. Par exemple, vous pouvez utiliser .to(“cuda:0”), .to(“cuda:1”), et ainsi de suite.

Cependant, tous les tensors doivent être sur le même device. Sinon, le calcul échouera, si un tensor réside sur le CPU et l’autre sur le GPU :

try:
    print(tensor_1 + tensor_2)
except RuntimeError as e:
    print(f"Erreur : {e}")
tensor([5., 7., 9.], device='cuda:0')

Entraînement sur un seul GPU (Single-GPU)

Une boucle d’entraînement sur un GPU

torch.manual_seed(123)
model = NeuralNetwork(num_inputs=2, num_outputs=2)

# Defines a device varaible that defaults to a GPU
device = torch.device("cuda")
# Transfers the model onto the GPU
model = model.to(device)

optimizer = torch.optim.SGD(model.parameters(), lr=0.5)

num_epochs = 3

for epoch in range(num_epochs):
    model.train()
    for batch_idx, (features, labels) in enumerate(train_loader):
        # Transfers the data onto the GPU
        features, labels = features.to(device), labels.to(device)
        logits = model(features)
        loss = F.cross_entropy(logits, labels) # Loss function

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        ### LOGGING
        print(f"Epoch: {epoch+1:03d}/{num_epochs:03d}"
              f" | Batch {batch_idx:03d}/{len(train_loader):03d}"
              f" | Train/ Val Loss: {loss: .2f}")

    model.eval()
    # Insert optional model evaluation code
Epoch: 001/003 | Batch 000/002 | Train/ Val Loss:  0.75
Epoch: 001/003 | Batch 001/002 | Train/ Val Loss:  0.65
Epoch: 002/003 | Batch 000/002 | Train/ Val Loss:  0.44
Epoch: 002/003 | Batch 001/002 | Train/ Val Loss:  0.13
Epoch: 003/003 | Batch 000/002 | Train/ Val Loss:  0.03
Epoch: 003/003 | Batch 001/002 | Train/ Val Loss:  0.00

Le rôle de .to(device) : déclencher le transfert vers le GPU

À ce stade, le pipeline CPU est entièrement optimisé :

  • les workers ont construit le batch dans la shared memory ;
  • la Result Queue a transmis au processus principal une référence vers cette zone mémoire — et non une copie des données ;
  • si pin_memory=True, ce batch est déjà verrouillé dans une zone de RAM stable, prête pour un transfert DMA.

Pourtant, tout cela n’a encore rien envoyé sur le GPU. Le batch existe uniquement en RAM. Le GPU ne peut exécuter des calculs que sur des données résidant dans sa propre mémoire (VRAM) — il ne peut pas accéder directement à la RAM du CPU à chaque opération.

C’est précisément ce que déclenche explicitement la ligne :

features, labels = features.to(device), labels.to(device)

Cette instruction ordonne à PyTorch d’allouer un bloc en VRAM et d’y copier les valeurs numériques du tenseur depuis la RAM. Sans pin_memory, cette copie nécessite une étape intermédiaire côté CPU. Avec pin_memory=True, le transfert se fait directement via DMA, sans intervention du CPU.

Le pipeline complet est donc le suivant :

[Disque]
      │  lecture fichiers

[Workers CPU]
      │  transforms + collate_fn → tenseur en shared memory

[Result Queue]
      │  transmet une référence vers la shared memory (pas une copie)

[Main Process CPU]
      │  accède au batch via la référence
      │  (pin_memory : batch déjà en RAM verrouillée)

[.to(device)]
      │  déclenche le transfert RAM → VRAM
      │  (via DMA si pin_memory=True)

[GPU / VRAM]
      └── forward / backward / mise à jour des poids

Une contrainte supplémentaire rend ce transfert obligatoire : PyTorch exige que toutes les opérations impliquent des tenseurs sur le même device. Le modèle ayant été placé en VRAM via model.to(device), toute donnée qui lui est passée doit impérativement s’y trouver également — sans quoi PyTorch lève une erreur d’exécution immédiate.

Ainsi, .to(device) n’est pas un détail technique annexe. C’est le dernier maillon du pipeline de chargement, celui qui rend les données effectivement exploitables par le GPU pour le calcul.


Ordonnancement des batchs et traitement par le GPU

Les sections précédentes ont montré comment les workers préparent les batchs en parallèle. Une question se pose alors naturellement : dans quel ordre le GPU traite-t-il ces batchs, sachant que plusieurs workers progressent simultanément et ne terminent pas nécessairement au même moment ?

Les workers ne finissent pas dans un ordre prévisible.

Bien que tous les workers démarrent leur traitement de façon parallèle, leur durée d’exécution n’est pas uniforme. Deux facteurs principaux introduisent des écarts :

  • le scheduler (l’ordonnanceur du système d’exploitation, c’est-à-dire le composant qui décide quel processus a le droit d’utiliser le CPU à un instant donné) n’alloue pas le temps de calcul de façon strictement équitable. D’autres processus système peuvent s’intercaler, suspendre temporairement un worker et retarder sa progression indépendamment du travail qu’il effectue ;
  • les accès disque peuvent être inégaux, notamment sur disque dur mécanique (HDD) où une tête de lecture physique se déplace pour atteindre les fichiers. Si les exemples d’un batch sont dispersés sur le disque, ce déplacement prend plus de temps que pour des fichiers physiquement proches. Sur SSD ce facteur est atténué, mais une contention subsiste lorsque plusieurs workers lisent simultanément et se disputent la bande passante de lecture.

Il est donc tout à fait possible que le worker chargé du batch #2 termine avant celui chargé du batch #0.

PyTorch garantit néanmoins l’ordre des batchs.

Pour maintenir un ordre déterministe, PyTorch associe à chaque groupe d’indices un numéro de séquence au moment où le BatchSampler le génère :

BatchSampler génère :
  batch #0 → indices [0,1,2,3]
  batch #1 → indices [4,5,6,7]
  batch #2 → indices [8,9,10,11]

Ce numéro accompagne le batch tout au long de son traitement. Lorsque les workers déposent leurs résultats dans la Result Queue, celle-ci peut donc recevoir les batchs dans un ordre quelconque :

Result Queue (ordre d'arrivée réel) :
  (numéro=2, batch_2)
  (numéro=0, batch_0)
  (numéro=1, batch_1)

Le processus principal ne consomme jamais simplement le premier batch disponible. Il attend spécifiquement le numéro de séquence suivant attendu. Les batchs arrivés en avance sont conservés dans un buffer interne jusqu’à ce que leur tour arrive :

Main process attend #0 :
  → batch #2 arrive → placé en buffer
  → batch #0 arrive → transmis au GPU  ✅

Main process attend #1 :
  → batch #1 arrive → transmis au GPU  ✅

Main process attend #2 : → batch #2 déjà en buffer → transmis au GPU ✅


**Le GPU traite les batchs séquentiellement.**

Un GPU standard ne traite qu'un seul batch à la fois. Le parallélisme des workers ne vise donc pas à alimenter plusieurs calculs GPU simultanément — il vise uniquement à **éliminer les temps d'attente** du GPU entre deux batchs. Sans workers, le GPU resterait inactif pendant toute la durée de préparation du batch suivant. Avec plusieurs workers, ce batch est déjà prêt dans la Result Queue avant même que le GPU en ait besoin.

Le pipeline complet peut donc se résumer ainsi :

```output
[Workers — parallèle]
  W1 : batch #1 ──┐
  W2 : batch #0 ──┼──> Result Queue (ordre quelconque)
  W3 : batch #2 ──┘           │

                      réordonnancement 
                    par numéro de séquence
                              |
                              |

            [Main Process] → [GPU] : #0 → #1 → #2 → ...
                                     (ordre garanti, flux continu)

Le parallélisme est donc entièrement localisé côté préparation CPU. Le GPU reste un consommateur séquentiel, mais un consommateur qui ne manque presque jamais de données grâce aux batchs préparés en avance par les workers.


NB : Buffer signifie littéralement une zone d’attente temporaire en mémoire.

Dans ce contexte précis : c’est simplement un dictionnaire Python que PyTorch maintient dans le processus principal, où il range les batchs reçus “trop tôt” en attendant que leur numéro de séquence soit le bon.

Concrètement :

Main process attend le numéro #0.

→ batch #2 arrive en premier dans la Result Queue.
  Le processus principal le récupère, voit que c'est le #2,
  et le range dans le buffer :
  buffer = { 2: batch_2 }

→ batch #0 arrive enfin.
  C'est le bon numéro → transmis immédiatement au GPU.
  buffer = { 2: batch_2 }  ← toujours là, attend son tour

→ Main process attend maintenant le #1.
  batch #1 arrive → transmis au GPU.

→ Main process attend le #2.
  Déjà dans le buffer → récupéré directement, transmis au GPU.
  buffer = {}  ← vidé

Le buffer évite de rejeter ou d’ignorer un batch arrivé en avance — il le conserve simplement en RAM jusqu’au bon moment. C’est juste une structure d’attente, rien de plus.

Nous pouvons utiliser .to("cuda") au lieu de device = torch.device("cuda"). Transférer un tensor vers cuda au lieu de torch.device("cuda") fonctionne également et est plus court.


Nous pouvons également modifier l’instruction, ce qui rendra le même code exécutable sur un CPU si un GPU n’est pas disponible. Ceci est considéré comme une bonne pratique lors du partage d’un code PyTorch :

device = torch.device("cuda" if torch.cuda_is_available() else "cpu")


Sur un Mac Apple avec une puce Apple Silicon (comme les M1, M2, M3, ou modèles plus récents) au lieu d’un ordinateur avec un GPU Nvidia, vous pouvez changer

device = torch.device("cuda" if torch.cuda_is_available() else "cpu")

en

device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")

Exercice A.4

Comparez le temps d’exécution de la multiplication matricielle sur un CPU par rapport à un GPU.

À partir de quelle taille de matrice commencez-vous à voir la multiplication matricielle sur le GPU être plus rapide que sur le CPU ? Indication : utilisez la commande %timeit dans Jupyter pour comparer le temps d’exécution. Par exemple, étant données les matrices a et b, exécutez la commande %timeit a @ b dans une nouvelle cellule du notebook.

size = 32

import torch
size = (32, 32)
# Matrices sur CPU
a_cpu = torch.randn(size)
b_cpu = torch.randn(size)

# Les mêmes matrices transférées sur GPU
a_gpu = a_cpu.to("cuda")
b_gpu = b_cpu.to("cuda")
%timeit a_cpu @ b_cpu
6.1 µs ± 1.17 µs per loop (mean ± std. dev. of 7 runs, 100000 loops each)
%timeit a_gpu @ b_gpu
15 µs ± 1.57 µs per loop (mean ± std. dev. of 7 runs, 100000 loops each)

size = 64

import torch
size = (64, 64)
# Matrices sur CPU
a_cpu = torch.randn(size)
b_cpu = torch.randn(size)

# Les mêmes matrices transférées sur GPU
a_gpu = a_cpu.to("cuda")
b_gpu = b_cpu.to("cuda")
%timeit a_cpu @ b_cpu
8.51 µs ± 217 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
%timeit a_gpu @ b_gpu
14.3 µs ± 1.86 µs per loop (mean ± std. dev. of 7 runs, 100000 loops each)

size = 128

import torch
size = (128, 128)
# Matrices sur CPU
a_cpu = torch.randn(size)
b_cpu = torch.randn(size)

# Les mêmes matrices transférées sur GPU
a_gpu = a_cpu.to("cuda")
b_gpu = b_cpu.to("cuda")
%timeit a_cpu @ b_cpu
54 µs ± 12.5 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
%timeit a_gpu @ b_gpu
13.3 µs ± 1.14 µs per loop (mean ± std. dev. of 7 runs, 100000 loops each)

size = 256

import torch
size = (256, 256)
# Matrices sur CPU
a_cpu = torch.randn(size)
b_cpu = torch.randn(size)

# Les mêmes matrices transférées sur GPU
a_gpu = a_cpu.to("cuda")
b_gpu = b_cpu.to("cuda")
%timeit a_cpu @ b_cpu
276 µs ± 6.94 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
%timeit a_gpu @ b_gpu
21.7 µs ± 247 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

size = 512

import torch
size = (512, 512)
# Matrices sur CPU
a_cpu = torch.randn(size)
b_cpu = torch.randn(size)

# Les mêmes matrices transférées sur GPU
a_gpu = a_cpu.to("cuda")
b_gpu = b_cpu.to("cuda")
%timeit a_cpu @ b_cpu
2.04 ms ± 100 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit a_gpu @ b_gpu
64.5 µs ± 196 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

size = 1024

import torch
size = (1024, 1024)
# Matrices sur CPU
a_cpu = torch.randn(size)
b_cpu = torch.randn(size)

# Les mêmes matrices transférées sur GPU
a_gpu = a_cpu.to("cuda")
b_gpu = b_cpu.to("cuda")
%timeit a_cpu @ b_cpu
16.8 ms ± 2.65 ms per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit a_gpu @ b_gpu
603 µs ± 3.74 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Entraînement avec plusieurs GPU

DDP (Distributed DataParallel) permet le parallélisme en répartissant les données d’entrée sur les appareils disponibles et en traitant ces sous-ensembles de données simultanément.

PyTorch lance un processus séparé sur chaque GPU, et chaque processus reçoit et conserve une copie du modèle ; ces copies seront synchronisées pendant l’entraînement.

Pour illustrer cela, supposons que nous avons deux GPU que nous voulons utiliser pour entraîner un réseau de neurones comme montré dans la figure ci-dessous :

Le transfert du modèle et des données dans DDP implique deux étapes clés. Tout d’abord, nous créons une copie du modèle sur chacun des GPU. Ensuite, nous divisons les données d’entrée en minibatches uniques que nous transmettons à chaque copie du modèle.

Chacun des deux GPU recevra une copie du modèle. Ensuite, à chaque itération d’entraînement, chaque modèle recevra un minibatch (ou simplement batch) du data loader. Nous pouvons utiliser un DistributedSampler pour nous assurer que chaque GPU recevra un batch différent et sans chevauchement lors de l’utilisation de DDP.

Puisque chaque copie du modèle verra un échantillon différent de données d’entraînement, les copies du modèle renverront des logits différents en tant que sorties et calculeront des gradients différents pendant la backward pass. Ces gradients sont ensuite moyennés et synchronisés pendant l’entraînement pour mettre à jour les modèles. De cette façon, nous nous assurons que les modèles ne divergent pas comme illustré dans la figure ci-dessous :

Les forward passes et backward passes dans DDP sont exécutées indépendamment sur chaque GPU avec son sous-ensemble de données correspondant. Une fois les forward et backward passes terminées, les gradients de chaque réplique du modèle (sur chaque GPU) sont synchronisés sur tous les GPU. Cela garantit que chaque réplique du modèle possède les mêmes poids mis à jour.

L’avantage d’utiliser DDP réside dans l’accélération qu’il offre pour le traitement du jeu de données par rapport à un GPU unique. Mis à part un léger surcoût de communication entre les appareils (inhérent à l’utilisation de DDP), il peut théoriquement traiter une époque d’entraînement en deux fois moins de temps avec deux GPU par rapport à un seul. Ce gain de temps passe à l’échelle (ou évolue proportionnellement) avec le nombre de GPU, ce qui nous permet de traiter une époque huit fois plus rapidement si nous disposons de huit GPU, et ainsi de suite.

NB : DDP ne fonctionne pas correctement dans des environnements Python interactifs comme les notebooks Jupyter, qui ne gèrent pas le multitraitement (multiprocessing) de la même manière qu’un script Python autonome. Par conséquent, le code suivant doit être exécuté en tant que script, et non au sein d’une interface de notebook comme Jupyter. DDP a besoin de générer plusieurs processus, et chaque processus doit disposer de sa propre instance d’interpréteur Python


SÉLECTIONNER LES GPU DISPONIBLES SUR UNE MACHINE MULTI-GPU

Si vous souhaitez restreindre le nombre de GPU utilisés pour l’entraînement sur une machine multi-GPU, le moyen le plus simple est d’utiliser la variable d’environnement CUDA_VISIBLE_DEVICES. Pour illustrer cela, supposons que votre machine possède plusieurs GPU et que vous ne souhaitiez en utiliser qu’un seul — par exemple, le GPU portant l’indice 0. Au lieu de python some_script.py, vous pouvez exécuter la commande suivante depuis le terminal :

CUDA_VISIBLE_DEVICES=0 python some_script.py

Ou, si votre machine possède quatre GPU et que vous souhaitez uniquement utiliser le premier et le troisième GPU, vous pouvez utiliser :

CUDA_VISIBLE_DEVICES=0,2 python some_script.py

Définir CUDA_VISIBLE_DEVICES de cette manière est un moyen simple et efficace de gérer l’allocation des GPU sans avoir à modifier vos scripts PyTorch.

Sorties dupliquées et contrôle par le rang

Comme le montre la sortie ci-dessous, les lignes de précision apparaissent en double à la fin de l’exécution sur une machine avec deux GPUs:

PyTorch version: 2.2.1+cu117
CUDA available: True
Number of GPUs available: 2
[GPU1] Epoch: 001/003 | Batchsize 002 | Train/Val Loss: 0.60
[GPU0] Epoch: 001/003 | Batchsize 002 | Train/Val Loss: 0.59
[GPU0] Epoch: 002/003 | Batchsize 002 | Train/Val Loss: 0.16
[GPU1] Epoch: 002/003 | Batchsize 002 | Train/Val Loss: 0.17
[GPU0] Epoch: 003/003 | Batchsize 002 | Train/Val Loss: 0.05
[GPU1] Epoch: 003/003 | Batchsize 002 | Train/Val Loss: 0.05
[GPU1] Training accuracy 1.0
[GPU0] Training accuracy 1.0   ← même valeur, affichée deux fois
[GPU1] Test accuracy 1.0
[GPU0] Test accuracy 1.0       ← même valeur, affichée deux fois

Ce comportement n’est pas un bug — c’est une conséquence directe du fonctionnement de DDP.

Lorsque DDP est utilisé, le script Python est exécuté en intégralité par chaque processus. Avec deux GPUs, deux processus indépendants tournent simultanément sur la machine :

  • le Processus 0 exécute le script du début à la fin et gère le GPU0 ;
  • le Processus 1 exécute le même script du début à la fin et gère le GPU1.

Chaque processus atteint donc la ligne :

print(f"Training accuracy {accuracy}")

et l’exécute indépendamment. Le terminal affiche la sortie combinée des deux processus, ce qui produit les lignes dupliquées observées.

On remarque également que les deux processus affichent la même précision. Ce n’est pas une coïncidence — c’est la conséquence directe de la synchronisation des gradients opérée par DDP à chaque batch :

  1. chaque GPU calcule les gradients sur sa portion de données ;
  2. DDP fait la moyenne de ces gradients entre tous les processus (AllReduce) ;
  3. chaque GPU met à jour ses poids avec cette même moyenne.

Les deux modèles reçoivent donc exactement la même mise à jour à chaque étape, arrivent à des poids identiques en fin d’entraînement, et produisent forcément la même précision sur le jeu de test.

Afficher les deux résultats est donc entièrement redondant. Pour y remédier, DDP attribue à chaque processus un rang (rank), c’est-à-dire un numéro d’identité unique (0, 1, 2, …). Il suffit de conditionner les affichages au seul processus chef :

if rank == 0:
    print(f"Test accuracy: {accuracy}")

Le processus 1 lit cette condition, constate qu’il n’est pas le rang 0, et ne produit aucune sortie. Seul le processus 0 s’exprime — les duplications disparaissent.

C’est une règle générale sous DDP : toute opération qui ne doit être effectuée qu’une seule fois — sauvegarder un checkpoint, loguer des métriques, afficher des résultats — doit être protégée par un if rank == 0.


Alternatives à DDP pour l’entraînement multi-GPU

DDP n’est pas la seule façon d’entraîner un modèle sur plusieurs GPUs avec PyTorch. Si vous préférez une approche plus simple et moins verbeuse, des bibliothèques complémentaires comme Fabric (open-source) permettent d’obtenir le même résultat avec beaucoup moins de code boilerplate.

L’auteur mentionne également deux techniques avancées abordées dans l’article “Accelerating PyTorch Model Training: Using Mixed-Precision and Fully Sharded Data Parallelism” (disponible à l’adresse https://mng.bz/jXle) :

  • la mixed-precision (précision mixte) : au lieu d’effectuer tous les calculs en float32 (32 bits), certaines opérations sont réalisées en float16 (16 bits). Les calculs sont plus rapides, la mémoire GPU consommée est réduite de moitié sur ces opérations — avec une perte de précision négligeable en pratique pour l’entraînement.

  • le Fully Sharded Data Parallelism (FSDP) : une extension de DDP où non seulement les données sont distribuées entre les GPUs, mais aussi les poids du modèle lui-même. Chaque GPU ne stocke qu’une fraction des paramètres. Cela permet d’entraîner des modèles bien trop volumineux pour tenir entièrement dans la VRAM d’un seul GPU — ce qui est précisément le cas des très grands modèles de langage.

Ces deux techniques répondent donc à des problèmes distincts : la mixed-precision accélère et allège l’entraînement, tandis que FSDP repousse les limites de taille des modèles entraînables.

Weekly Notes

Every Sunday, I share what I’ve been learning — papers, ideas, experiments, and questions that stayed with me.

You can unsubscribe at any time with a single click.

0 Likes • 0 Comments

Discussion about this post0

Join the discussion

A secure sign-in link will be sent to your email address.

Loading discussion...